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
|
|
||||||
@@ -19,7 +19,4 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
|||||||
WEB_APP_URL=https://your-domain.com
|
WEB_APP_URL=https://your-domain.com
|
||||||
|
|
||||||
# Optional: Additional database settings
|
# Optional: Additional database settings
|
||||||
DATABASE_URL=postgresql://quixotic:your_secure_postgres_password_here@postgres:5432/quixotic
|
DATABASE_URL=postgresql://quixotic:your_secure_postgres_password_here@postgres:5432/quixotic
|
||||||
# VK Music Configuration (add your actual credentials)
|
|
||||||
VK_LOGIN=your_vk_login_here
|
|
||||||
VK_PASSWORD=your_vk_password_here
|
|
||||||
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
|
||||||
|
|||||||
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
# Backend Migration to Python Successfully Completed
|
|
||||||
|
|
||||||
## Status: ✅ SUCCESSFUL
|
|
||||||
The Telegram bot conflict error has been resolved and Python backend is running.
|
|
||||||
|
|
||||||
## What Was Fixed:
|
|
||||||
1. **Stopped old TypeScript server** - Resolved 409 Conflict error from multiple bot instances
|
|
||||||
2. **Added missing dependency** - beautifulsoup4 required by vk-api package
|
|
||||||
3. **Updated Docker configuration** - Changed port from 3000 to 8000
|
|
||||||
4. **Clean deployment** - Python backend now running without errors
|
|
||||||
|
|
||||||
## Current Python Backend:
|
|
||||||
- **Status**: Running successfully ✅
|
|
||||||
- **URL**: http://localhost:8000 (internal Docker: port 8000)
|
|
||||||
- **Services**: FastAPI + PostgreSQL + Traefik
|
|
||||||
- **Music Source**: VK Music (vk-api library)
|
|
||||||
- **Telegram Bot**: No conflicts, single instance running
|
|
||||||
|
|
||||||
## Logs Confirmation:
|
|
||||||
```
|
|
||||||
INFO: Started server process [1]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints Available:
|
|
||||||
- GET / - Main HTML page
|
|
||||||
- POST /api/search - Search VK Music
|
|
||||||
- POST /api/convert - Convert to MP3
|
|
||||||
- POST /api/telegram-send - Send via bot
|
|
||||||
- GET /health - Health check
|
|
||||||
|
|
||||||
## Environment Variables Needed:
|
|
||||||
To fully test VK Music functionality, configure:
|
|
||||||
- VK_LOGIN - VK account login
|
|
||||||
- VK_PASSWORD - VK account password
|
|
||||||
- TELEGRAM_BOT_TOKEN - Bot token
|
|
||||||
- DATABASE_URL - PostgreSQL connection
|
|
||||||
|
|
||||||
## Next Steps:
|
|
||||||
Ready for testing with actual VK credentials and Telegram bot integration.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Python Backend Rewrite Complete
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Successfully rewrote the Node.js/TypeScript backend to Python with VK Music integration.
|
|
||||||
|
|
||||||
## New Python Backend Structure
|
|
||||||
```
|
|
||||||
├── main.py # Entry point - FastAPI app with uvicorn
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── setup.py # Environment setup script
|
|
||||||
├── backend/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── database.py # SQLAlchemy models and database connection
|
|
||||||
│ ├── vkmusic.py # VK Music API integration (replaced SoundCloud)
|
|
||||||
│ ├── api.py # FastAPI routes and endpoints
|
|
||||||
│ └── bot.py # Telegram bot functionality
|
|
||||||
├── Dockerfile # Updated for Python
|
|
||||||
└── .env # Environment variables (created by setup.py)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Changes Made
|
|
||||||
1. **Framework**: Express.js → FastAPI
|
|
||||||
2. **Music Service**: SoundCloud → VK Music (vk-api library)
|
|
||||||
3. **Database**: Direct PostgreSQL → SQLAlchemy ORM
|
|
||||||
4. **Bot**: node-telegram-bot-api → python-telegram-bot
|
|
||||||
5. **Audio Processing**: fluent-ffmpeg → subprocess + FFmpeg
|
|
||||||
|
|
||||||
## API Endpoints (Preserved)
|
|
||||||
- `GET /` - Serve main HTML with cache-busting
|
|
||||||
- `POST /api/search` - Search VK Music tracks
|
|
||||||
- `POST /api/convert` - Convert audio to MP3
|
|
||||||
- `POST /api/telegram-send` - Send audio via Telegram
|
|
||||||
- `GET /health` - Health check
|
|
||||||
- `GET /downloads/{filename}` - Serve MP3 files
|
|
||||||
|
|
||||||
## Environment Variables Required
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
|
||||||
- `VK_LOGIN` - VK account login
|
|
||||||
- `VK_PASSWORD` - VK account password
|
|
||||||
- `TELEGRAM_BOT_TOKEN` - Telegram bot token
|
|
||||||
- `WEB_APP_URL` - Web app URL for bot
|
|
||||||
- `PORT` - Server port (default: 8000)
|
|
||||||
- `HOST` - Server host (default: 0.0.0.0)
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
1. Run `python setup.py` to initialize environment
|
|
||||||
2. Update `.env` file with actual credentials
|
|
||||||
3. Install dependencies: `pip install -r requirements.txt`
|
|
||||||
4. Start server: `python main.py`
|
|
||||||
5. Access API at: http://localhost:8000
|
|
||||||
|
|
||||||
## Docker Support
|
|
||||||
Updated Dockerfile uses Python 3.11-slim with ffmpeg support. Ready for containerized deployment.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
- Test VK Music integration with actual credentials
|
|
||||||
- Update frontend if needed to work with new Python API
|
|
||||||
- Deploy and test in production environment
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Search Bar Clear Button Implementation Complete
|
|
||||||
|
|
||||||
## Task Completed
|
|
||||||
Successfully implemented a clear button for the search bar and auto-focus functionality when the user opens the app.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. HTML Structure (public/index.html)
|
|
||||||
- Added clear button inside the input wrapper:
|
|
||||||
```html
|
|
||||||
<button class="tg-input-clear" id="clearButton" style="display: none;" type="button">✕</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. CSS Styles (public/style.css)
|
|
||||||
- Added comprehensive styling for the clear button:
|
|
||||||
- Positioned absolutely within input wrapper
|
|
||||||
- Circular design with hover and active states
|
|
||||||
- Proper Telegram theme color integration
|
|
||||||
- Hidden by default, shown when input has content
|
|
||||||
|
|
||||||
### 3. TypeScript Functionality (public/script.ts)
|
|
||||||
- Added clearButton property to QuixoticApp class
|
|
||||||
- Implemented `clearSearch()` method to clear input and reset state
|
|
||||||
- Added `updateClearButtonVisibility()` to show/hide button based on input content
|
|
||||||
- Integrated clear button event listener in `bindEvents()`
|
|
||||||
- Added auto-focus functionality with 100ms delay on app initialization
|
|
||||||
|
|
||||||
### 4. JavaScript Compilation
|
|
||||||
- Successfully compiled TypeScript to JavaScript using `npx tsc --skipLibCheck`
|
|
||||||
- Generated script.js in public/dist/ directory
|
|
||||||
|
|
||||||
## Key Features Implemented
|
|
||||||
1. ✅ Clear button appears when user types in search bar
|
|
||||||
2. ✅ Clear button disappears when input is empty
|
|
||||||
3. ✅ Clicking clear button clears input and resets to welcome state
|
|
||||||
4. ✅ Auto-focus search input when app loads to activate keyboard
|
|
||||||
5. ✅ Maintains focus after clearing to continue typing
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
- Clear button uses ✕ symbol for intuitive UX
|
|
||||||
- Styled with Telegram theme colors for consistency
|
|
||||||
- Proper event handling to prevent conflicts with existing search functionality
|
|
||||||
- TypeScript compiled successfully with library skip for dependency issues
|
|
||||||
|
|
||||||
All functionality is working and ready for use.
|
|
||||||
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
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# UV Package Manager Usage Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
UV is a fast Python package manager and project management tool, designed as a drop-in replacement for pip and virtualenv.
|
|
||||||
|
|
||||||
## Key Commands Used
|
|
||||||
|
|
||||||
### Package Installation
|
|
||||||
```bash
|
|
||||||
# Install packages from requirements.txt
|
|
||||||
uv pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Install specific packages
|
|
||||||
uv pip install fastapi uvicorn aiofiles pydantic
|
|
||||||
|
|
||||||
# Install with specific Python version
|
|
||||||
uv pip install --python 3.13 package_name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Management
|
|
||||||
```bash
|
|
||||||
# Create virtual environment
|
|
||||||
uv venv
|
|
||||||
|
|
||||||
# Create with specific Python version
|
|
||||||
uv venv --python 3.13
|
|
||||||
|
|
||||||
# Activate environment (still use standard activation)
|
|
||||||
source .venv/bin/activate # Linux/Mac
|
|
||||||
.venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Management
|
|
||||||
```bash
|
|
||||||
# Initialize new project
|
|
||||||
uv init
|
|
||||||
|
|
||||||
# Add dependencies
|
|
||||||
uv add fastapi uvicorn
|
|
||||||
|
|
||||||
# Remove dependencies
|
|
||||||
uv remove package_name
|
|
||||||
|
|
||||||
# Sync dependencies
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Commands
|
|
||||||
```bash
|
|
||||||
# Run Python with uv environment
|
|
||||||
uv run python script.py
|
|
||||||
|
|
||||||
# Run with specific requirements
|
|
||||||
uv run --with requests python script.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advantages Over Pip
|
|
||||||
- Much faster installation and dependency resolution
|
|
||||||
- Better dependency conflict resolution
|
|
||||||
- Built-in virtual environment management
|
|
||||||
- Lockfile support for reproducible builds
|
|
||||||
- Cross-platform compatibility
|
|
||||||
|
|
||||||
## Usage in This Project
|
|
||||||
- Used `uv pip install` to install FastAPI dependencies
|
|
||||||
- Works with existing requirements.txt files
|
|
||||||
- Automatically resolves and installs dependencies
|
|
||||||
- Handles complex package builds (though some like psycopg2 may still have issues on certain Python versions)
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- Use `uv pip install` as drop-in replacement for `pip install`
|
|
||||||
- Create virtual environments with `uv venv`
|
|
||||||
- Use `uv sync` for consistent dependency management
|
|
||||||
- Check version with `uv --version`
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# * For JavaScript, use typescript
|
# * For JavaScript, use typescript
|
||||||
# Special requirements:
|
# Special requirements:
|
||||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
language: go
|
language: typescript
|
||||||
|
|
||||||
# whether to use the project's gitignore file to ignore files
|
# whether to use the project's gitignore file to ignore files
|
||||||
# Added on 2025-04-07
|
# Added on 2025-04-07
|
||||||
|
|||||||
61
Dockerfile
61
Dockerfile
@@ -1,44 +1,65 @@
|
|||||||
# Python Backend Dockerfile
|
# Build stage
|
||||||
FROM python:3.11-slim
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Copy package files first (better caching)
|
||||||
RUN apt-get update && apt-get install -y \
|
COPY package*.json ./
|
||||||
ffmpeg \
|
COPY yarn.lock* ./
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
# Install all dependencies (including dev for build)
|
||||||
|
# This layer will be cached unless package.json changes
|
||||||
|
RUN yarn install --frozen-lockfile && yarn cache clean
|
||||||
|
|
||||||
|
# Copy source code (separate from dependencies)
|
||||||
|
COPY tsconfig*.json ./
|
||||||
|
COPY eslint.config.mjs ./
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
# Build the application with minification
|
||||||
|
RUN yarn build:prod
|
||||||
|
|
||||||
|
# Clean dev dependencies
|
||||||
|
RUN yarn install --production --frozen-lockfile
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine AS production
|
||||||
|
|
||||||
|
# Install ffmpeg from Alpine packages (architecture-aware)
|
||||||
|
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
|
||||||
|
|
||||||
# Copy requirements first for better Docker layer caching
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
# Copy built application and dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
# Copy application code
|
COPY --from=builder /app/public ./public
|
||||||
COPY backend/ ./backend/
|
COPY --from=builder /app/package*.json ./
|
||||||
COPY main.py .
|
|
||||||
COPY public/ ./public/
|
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
RUN mkdir -p downloads database
|
RUN mkdir -p downloads database
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN groupadd -r quixotic && useradd -r -g quixotic quixotic
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S quixotic -u 1001
|
||||||
|
|
||||||
# Change ownership of app directory
|
# Change ownership of app directory
|
||||||
RUN chown -R quixotic:quixotic /app
|
RUN chown -R quixotic:nodejs /app
|
||||||
USER quixotic
|
USER quixotic
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=3)"
|
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
CMD ["python", "main.py"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Backend package
|
|
||||||
Binary file not shown.
Binary file not shown.
336
backend/api.py
336
backend/api.py
@@ -1,336 +0,0 @@
|
|||||||
import os
|
|
||||||
import asyncio
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import aiofiles
|
|
||||||
|
|
||||||
from .database import Database, User
|
|
||||||
from .vkmusic import VKMusicService, Track
|
|
||||||
from .bot import QuixoticBot
|
|
||||||
|
|
||||||
app = FastAPI(title="Quixotic API", version="1.0.0")
|
|
||||||
|
|
||||||
# Initialize services
|
|
||||||
db = Database()
|
|
||||||
vkmusic = VKMusicService()
|
|
||||||
bot_instance = None
|
|
||||||
|
|
||||||
# Ensure downloads directory exists
|
|
||||||
downloads_dir = Path(__file__).parent.parent / "downloads"
|
|
||||||
downloads_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# Request/Response models
|
|
||||||
class SearchRequest(BaseModel):
|
|
||||||
query: str
|
|
||||||
userId: Optional[str] = None
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
|
||||||
videos: List[Track]
|
|
||||||
|
|
||||||
class ConvertRequest(BaseModel):
|
|
||||||
videoId: str
|
|
||||||
title: Optional[str] = None
|
|
||||||
userId: Optional[str] = None
|
|
||||||
url: Optional[str] = None
|
|
||||||
performer: Optional[str] = None
|
|
||||||
|
|
||||||
class ConvertResponse(BaseModel):
|
|
||||||
audioUrl: str
|
|
||||||
title: Optional[str] = None
|
|
||||||
|
|
||||||
class TelegramSendRequest(BaseModel):
|
|
||||||
userId: str
|
|
||||||
audioUrl: str
|
|
||||||
title: str
|
|
||||||
performer: Optional[str] = None
|
|
||||||
thumbnail: Optional[str] = None
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
status: str
|
|
||||||
timestamp: str
|
|
||||||
|
|
||||||
# Routes
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def root(request: Request):
|
|
||||||
"""Serve main HTML page with cache-busting"""
|
|
||||||
try:
|
|
||||||
public_dir = Path(__file__).parent.parent / "public"
|
|
||||||
index_path = public_dir / "index.html"
|
|
||||||
|
|
||||||
async with aiofiles.open(index_path, 'r', encoding='utf8') as f:
|
|
||||||
html = await f.read()
|
|
||||||
|
|
||||||
# Add timestamp for cache busting
|
|
||||||
timestamp = int(datetime.now().timestamp() * 1000)
|
|
||||||
html = html.replace('dist/script.js?v=2', f'dist/script.js?v={timestamp}')
|
|
||||||
|
|
||||||
return HTMLResponse(
|
|
||||||
content=html,
|
|
||||||
headers={
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to serve index: {str(e)}")
|
|
||||||
|
|
||||||
@app.post("/api/search", response_model=SearchResponse)
|
|
||||||
async def search_videos(request: SearchRequest):
|
|
||||||
"""Search for videos/tracks"""
|
|
||||||
try:
|
|
||||||
if not request.query or not request.query.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="Query is required")
|
|
||||||
|
|
||||||
# Save search history
|
|
||||||
if request.userId and request.userId != 'demo':
|
|
||||||
try:
|
|
||||||
user = await db.get_user_by_telegram_id(request.userId)
|
|
||||||
if user:
|
|
||||||
await db.add_search_history(user.id, request.query)
|
|
||||||
except Exception as db_error:
|
|
||||||
print(f"Database error: {db_error}")
|
|
||||||
|
|
||||||
# Search for tracks
|
|
||||||
videos = await vkmusic.search_tracks(request.query.strip())
|
|
||||||
return SearchResponse(videos=videos)
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
print(f"Search error: {error}")
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to search videos")
|
|
||||||
|
|
||||||
@app.post("/api/convert", response_model=ConvertResponse)
|
|
||||||
async def convert_video(request: ConvertRequest, http_request: Request):
|
|
||||||
"""Convert video to MP3"""
|
|
||||||
try:
|
|
||||||
if not request.videoId:
|
|
||||||
raise HTTPException(status_code=400, detail="Video ID is required")
|
|
||||||
|
|
||||||
# Generate safe filename
|
|
||||||
safe_title = "".join(c for c in (request.title or "") if c.isalnum() or c in (' ', '-', '_')).replace(' ', '_')[:50]
|
|
||||||
filename = f"{request.videoId}_{safe_title}.mp3"
|
|
||||||
output_path = downloads_dir / filename
|
|
||||||
|
|
||||||
# Check if file already exists
|
|
||||||
if output_path.exists():
|
|
||||||
print("File already exists, serving cached version")
|
|
||||||
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
|
|
||||||
audio_url = f"{base_url}/downloads/{filename}"
|
|
||||||
return ConvertResponse(audioUrl=audio_url, title=request.title)
|
|
||||||
|
|
||||||
print(f"Starting MP3 conversion for: {request.title}")
|
|
||||||
|
|
||||||
# Download and convert using VK music service
|
|
||||||
if not request.url:
|
|
||||||
raise HTTPException(status_code=400, detail="Track URL is required")
|
|
||||||
|
|
||||||
success = await vkmusic.download_track(request.url, str(output_path))
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Fallback: try using FFmpeg conversion
|
|
||||||
audio_data = await vkmusic.get_audio_stream(request.videoId, request.url)
|
|
||||||
if not audio_data:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="MP3 conversion failed. This track may be restricted or unavailable for download."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert using FFmpeg
|
|
||||||
await convert_with_ffmpeg(audio_data, output_path)
|
|
||||||
|
|
||||||
# Save download record
|
|
||||||
if request.userId and request.userId != 'demo':
|
|
||||||
try:
|
|
||||||
user = await db.get_user_by_telegram_id(request.userId)
|
|
||||||
if user:
|
|
||||||
await db.add_download(user.id, request.videoId, request.title or "", str(output_path))
|
|
||||||
except Exception as db_error:
|
|
||||||
print(f"Database error: {db_error}")
|
|
||||||
|
|
||||||
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
|
|
||||||
audio_url = f"{base_url}/downloads/{filename}"
|
|
||||||
print(f"Conversion successful, file available at: {audio_url}")
|
|
||||||
|
|
||||||
return ConvertResponse(audioUrl=audio_url, title=request.title)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
print(f"Server error: {error}")
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to process request")
|
|
||||||
|
|
||||||
@app.post("/api/telegram-send")
|
|
||||||
async def telegram_send(request: TelegramSendRequest):
|
|
||||||
"""Send audio via Telegram bot"""
|
|
||||||
print("🚀 Telegram send request received")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not request.userId or not request.audioUrl or not request.title:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing required fields")
|
|
||||||
|
|
||||||
if not bot_instance:
|
|
||||||
raise HTTPException(status_code=500, detail="Bot not available")
|
|
||||||
|
|
||||||
print(f"📤 Sending to user {request.userId}: {request.title}")
|
|
||||||
|
|
||||||
chat_id = int(request.userId)
|
|
||||||
await bot_instance.send_audio_file(
|
|
||||||
chat_id,
|
|
||||||
request.audioUrl,
|
|
||||||
request.title,
|
|
||||||
request.performer,
|
|
||||||
request.thumbnail
|
|
||||||
)
|
|
||||||
|
|
||||||
print("✅ Audio sent successfully")
|
|
||||||
return {"success": True, "message": "Audio sent successfully"}
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
print(f"❌ Send failed: {error}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(error))
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse)
|
|
||||||
async def health_check():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return HealthResponse(
|
|
||||||
status="ok",
|
|
||||||
timestamp=datetime.now().isoformat()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serve download files
|
|
||||||
app.mount("/downloads", StaticFiles(directory=str(downloads_dir)), name="downloads")
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
public_dir = Path(__file__).parent.parent / "public"
|
|
||||||
|
|
||||||
@app.get("/style.css")
|
|
||||||
async def get_css():
|
|
||||||
"""Serve CSS file"""
|
|
||||||
css_path = public_dir / "style.css"
|
|
||||||
if css_path.exists():
|
|
||||||
return FileResponse(
|
|
||||||
path=css_path,
|
|
||||||
media_type="text/css",
|
|
||||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=404, detail="CSS not found")
|
|
||||||
|
|
||||||
@app.get("/dist/script.js")
|
|
||||||
async def get_js():
|
|
||||||
"""Serve JavaScript file"""
|
|
||||||
js_path = public_dir / "dist" / "script.js"
|
|
||||||
if js_path.exists():
|
|
||||||
return FileResponse(
|
|
||||||
path=js_path,
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=404, detail="JavaScript not found")
|
|
||||||
|
|
||||||
# Mount remaining static files
|
|
||||||
if public_dir.exists():
|
|
||||||
app.mount("/static", StaticFiles(directory=str(public_dir)), name="static")
|
|
||||||
|
|
||||||
async def convert_with_ffmpeg(audio_data: bytes, output_path: Path):
|
|
||||||
"""Convert audio data to MP3 using FFmpeg"""
|
|
||||||
temp_input = output_path.with_suffix('.tmp')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Write audio data to temporary file
|
|
||||||
async with aiofiles.open(temp_input, 'wb') as f:
|
|
||||||
await f.write(audio_data)
|
|
||||||
|
|
||||||
# Convert using FFmpeg
|
|
||||||
cmd = [
|
|
||||||
'ffmpeg',
|
|
||||||
'-i', str(temp_input),
|
|
||||||
'-codec:a', 'libmp3lame',
|
|
||||||
'-b:a', '192k',
|
|
||||||
'-ac', '2',
|
|
||||||
'-ar', '44100',
|
|
||||||
'-f', 'mp3',
|
|
||||||
str(output_path),
|
|
||||||
'-y' # Overwrite output file
|
|
||||||
]
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, stderr = await process.communicate()
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
print(f"FFmpeg error: {stderr.decode()}")
|
|
||||||
raise Exception("FFmpeg conversion failed")
|
|
||||||
|
|
||||||
print("MP3 conversion completed successfully")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up temporary file
|
|
||||||
if temp_input.exists():
|
|
||||||
temp_input.unlink()
|
|
||||||
|
|
||||||
# Cleanup task for old files
|
|
||||||
async def cleanup_old_files():
|
|
||||||
"""Clean up old download files"""
|
|
||||||
max_age = 24 * 60 * 60 # 24 hours in seconds
|
|
||||||
now = datetime.now().timestamp()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for file_path in downloads_dir.iterdir():
|
|
||||||
if file_path.is_file():
|
|
||||||
file_age = now - file_path.stat().st_mtime
|
|
||||||
if file_age > max_age:
|
|
||||||
file_path.unlink()
|
|
||||||
print(f"Deleted old file: {file_path.name}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Cleanup error: {e}")
|
|
||||||
|
|
||||||
# Startup event
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""Initialize services on startup"""
|
|
||||||
global bot_instance
|
|
||||||
|
|
||||||
print("Starting Quixotic Python API...")
|
|
||||||
|
|
||||||
# Initialize Telegram bot
|
|
||||||
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
||||||
web_app_url = os.getenv("WEB_APP_URL", "http://localhost:8000")
|
|
||||||
|
|
||||||
if bot_token and len(bot_token) > 10:
|
|
||||||
try:
|
|
||||||
bot_instance = QuixoticBot(bot_token, web_app_url, db)
|
|
||||||
print("🤖 Telegram bot initialized")
|
|
||||||
except Exception as error:
|
|
||||||
print(f"❌ Bot initialization failed: {error}")
|
|
||||||
print("⚠️ Bot disabled due to error")
|
|
||||||
else:
|
|
||||||
print("⚠️ TELEGRAM_BOT_TOKEN not found or invalid - bot will not start")
|
|
||||||
|
|
||||||
# Start cleanup task
|
|
||||||
asyncio.create_task(periodic_cleanup())
|
|
||||||
|
|
||||||
async def periodic_cleanup():
|
|
||||||
"""Periodic cleanup task"""
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(3600) # Run every hour
|
|
||||||
await cleanup_old_files()
|
|
||||||
|
|
||||||
# Shutdown event
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
async def shutdown_event():
|
|
||||||
"""Cleanup on shutdown"""
|
|
||||||
await vkmusic.close()
|
|
||||||
db.close()
|
|
||||||
if bot_instance:
|
|
||||||
await bot_instance.close()
|
|
||||||
print("Quixotic API shutdown complete")
|
|
||||||
163
backend/bot.py
163
backend/bot.py
@@ -1,163 +0,0 @@
|
|||||||
import os
|
|
||||||
from typing import Optional
|
|
||||||
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
|
||||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .database import Database, User
|
|
||||||
|
|
||||||
class QuixoticBot:
|
|
||||||
def __init__(self, token: str, web_app_url: str, database: Database):
|
|
||||||
self.token = token
|
|
||||||
self.web_app_url = web_app_url
|
|
||||||
self.db = database
|
|
||||||
self.bot = Bot(token=token)
|
|
||||||
self.application = Application.builder().token(token).build()
|
|
||||||
self.setup_handlers()
|
|
||||||
|
|
||||||
def setup_handlers(self):
|
|
||||||
"""Setup bot command and message handlers"""
|
|
||||||
# Command handlers
|
|
||||||
self.application.add_handler(CommandHandler("start", self.start_command))
|
|
||||||
self.application.add_handler(CommandHandler("help", self.help_command))
|
|
||||||
|
|
||||||
# Message handlers
|
|
||||||
self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
|
|
||||||
|
|
||||||
async def start_command(self, update, context):
|
|
||||||
"""Handle /start command"""
|
|
||||||
user = update.effective_user
|
|
||||||
chat_id = update.effective_chat.id
|
|
||||||
|
|
||||||
# Save or update user in database
|
|
||||||
try:
|
|
||||||
db_user = await self.db.get_user_by_telegram_id(str(user.id))
|
|
||||||
if not db_user:
|
|
||||||
await self.db.add_user(
|
|
||||||
telegram_id=str(user.id),
|
|
||||||
username=user.username,
|
|
||||||
first_name=user.first_name,
|
|
||||||
last_name=user.last_name,
|
|
||||||
language_code=user.language_code
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Database error in start command: {e}")
|
|
||||||
|
|
||||||
# Create inline keyboard with web app button
|
|
||||||
keyboard = [
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
"🎵 Open Quixotic",
|
|
||||||
web_app=WebAppInfo(url=self.web_app_url)
|
|
||||||
)]
|
|
||||||
]
|
|
||||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
|
||||||
|
|
||||||
welcome_text = (
|
|
||||||
f"👋 Welcome to Quixotic, {user.first_name}!\n\n"
|
|
||||||
"🎵 Search and download music from SoundCloud\n"
|
|
||||||
"🚀 Fast MP3 conversion\n"
|
|
||||||
"📱 Easy-to-use interface\n\n"
|
|
||||||
"Click the button below to get started!"
|
|
||||||
)
|
|
||||||
|
|
||||||
await context.bot.send_message(
|
|
||||||
chat_id=chat_id,
|
|
||||||
text=welcome_text,
|
|
||||||
reply_markup=reply_markup
|
|
||||||
)
|
|
||||||
|
|
||||||
async def help_command(self, update, context):
|
|
||||||
"""Handle /help command"""
|
|
||||||
help_text = (
|
|
||||||
"🎵 *Quixotic Bot Help*\n\n"
|
|
||||||
"*Commands:*\n"
|
|
||||||
"/start - Start the bot and open the music app\n"
|
|
||||||
"/help - Show this help message\n\n"
|
|
||||||
"*How to use:*\n"
|
|
||||||
"1. Click 'Open Quixotic' to launch the web app\n"
|
|
||||||
"2. Search for your favorite songs\n"
|
|
||||||
"3. Convert and download as MP3\n"
|
|
||||||
"4. Music files will be sent directly to this chat\n\n"
|
|
||||||
"*Features:*\n"
|
|
||||||
"• SoundCloud music search\n"
|
|
||||||
"• High-quality MP3 conversion\n"
|
|
||||||
"• Fast downloads\n"
|
|
||||||
"• Search history tracking\n\n"
|
|
||||||
"Enjoy your music! 🎶"
|
|
||||||
)
|
|
||||||
|
|
||||||
await update.message.reply_text(
|
|
||||||
help_text,
|
|
||||||
parse_mode='Markdown'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_message(self, update, context):
|
|
||||||
"""Handle text messages"""
|
|
||||||
# For now, just respond with instructions to use the web app
|
|
||||||
keyboard = [
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
"🎵 Open Quixotic",
|
|
||||||
web_app=WebAppInfo(url=self.web_app_url)
|
|
||||||
)]
|
|
||||||
]
|
|
||||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
|
||||||
|
|
||||||
await update.message.reply_text(
|
|
||||||
"Use the web app to search and download music! 🎵",
|
|
||||||
reply_markup=reply_markup
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_audio_file(self, chat_id: int, audio_url: str, title: str,
|
|
||||||
performer: Optional[str] = None, thumbnail: Optional[str] = None):
|
|
||||||
"""Send audio file to user"""
|
|
||||||
try:
|
|
||||||
print(f"📤 Sending audio to chat {chat_id}")
|
|
||||||
|
|
||||||
# Download the audio file first
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(audio_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
audio_data = response.content
|
|
||||||
|
|
||||||
# Send audio
|
|
||||||
await self.bot.send_audio(
|
|
||||||
chat_id=chat_id,
|
|
||||||
audio=audio_data,
|
|
||||||
title=title,
|
|
||||||
performer=performer or "Unknown Artist",
|
|
||||||
duration=None, # Let Telegram figure it out
|
|
||||||
caption=f"🎵 {title}" + (f" by {performer}" if performer else ""),
|
|
||||||
thumbnail=thumbnail if thumbnail else None
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"✅ Audio sent successfully to chat {chat_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Failed to send audio to chat {chat_id}: {e}")
|
|
||||||
|
|
||||||
# Send error message
|
|
||||||
error_message = (
|
|
||||||
"❌ Failed to send audio file. "
|
|
||||||
"The file might be too large or temporarily unavailable."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(chat_id=chat_id, text=error_message)
|
|
||||||
except Exception as msg_error:
|
|
||||||
print(f"❌ Failed to send error message: {msg_error}")
|
|
||||||
|
|
||||||
raise e
|
|
||||||
|
|
||||||
async def start_polling(self):
|
|
||||||
"""Start bot polling"""
|
|
||||||
print("🤖 Starting bot polling...")
|
|
||||||
await self.application.initialize()
|
|
||||||
await self.application.start()
|
|
||||||
await self.application.updater.start_polling()
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close bot application"""
|
|
||||||
if self.application:
|
|
||||||
await self.application.stop()
|
|
||||||
await self.application.shutdown()
|
|
||||||
print("🤖 Bot closed")
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List
|
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker, Session, relationship
|
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
telegram_id = Column(String, unique=True, nullable=False)
|
|
||||||
username = Column(String, nullable=True)
|
|
||||||
first_name = Column(String, nullable=True)
|
|
||||||
last_name = Column(String, nullable=True)
|
|
||||||
language_code = Column(String, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
search_history = relationship("SearchHistory", back_populates="user")
|
|
||||||
downloads = relationship("Download", back_populates="user")
|
|
||||||
|
|
||||||
class SearchHistory(Base):
|
|
||||||
__tablename__ = "search_history"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
query = Column(String, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationship
|
|
||||||
user = relationship("User", back_populates="search_history")
|
|
||||||
|
|
||||||
class Download(Base):
|
|
||||||
__tablename__ = "downloads"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
video_id = Column(String, nullable=False)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
file_path = Column(String, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationship
|
|
||||||
user = relationship("User", back_populates="downloads")
|
|
||||||
|
|
||||||
class Database:
|
|
||||||
def __init__(self):
|
|
||||||
# Get database URL from environment or use default
|
|
||||||
database_url = os.getenv("DATABASE_URL")
|
|
||||||
if not database_url:
|
|
||||||
raise ValueError("DATABASE_URL environment variable is required")
|
|
||||||
|
|
||||||
# Create engine
|
|
||||||
self.engine = create_engine(
|
|
||||||
database_url,
|
|
||||||
poolclass=StaticPool,
|
|
||||||
connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
|
|
||||||
echo=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create session factory
|
|
||||||
self.SessionLocal = sessionmaker(bind=self.engine)
|
|
||||||
|
|
||||||
# Create tables
|
|
||||||
self.init_db()
|
|
||||||
|
|
||||||
def init_db(self):
|
|
||||||
"""Initialize database tables"""
|
|
||||||
Base.metadata.create_all(bind=self.engine)
|
|
||||||
|
|
||||||
def get_session(self) -> Session:
|
|
||||||
"""Get database session"""
|
|
||||||
return self.SessionLocal()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""Close database connection"""
|
|
||||||
self.engine.dispose()
|
|
||||||
|
|
||||||
async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
|
|
||||||
"""Get user by Telegram ID"""
|
|
||||||
with self.get_session() as session:
|
|
||||||
return session.query(User).filter(User.telegram_id == telegram_id).first()
|
|
||||||
|
|
||||||
async def add_user(self, telegram_id: str, username: str = None,
|
|
||||||
first_name: str = None, last_name: str = None,
|
|
||||||
language_code: str = None) -> User:
|
|
||||||
"""Add new user"""
|
|
||||||
with self.get_session() as session:
|
|
||||||
user = User(
|
|
||||||
telegram_id=telegram_id,
|
|
||||||
username=username,
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
language_code=language_code
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def add_search_history(self, user_id: int, query: str) -> SearchHistory:
|
|
||||||
"""Add search history record"""
|
|
||||||
with self.get_session() as session:
|
|
||||||
history = SearchHistory(user_id=user_id, query=query)
|
|
||||||
session.add(history)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(history)
|
|
||||||
return history
|
|
||||||
|
|
||||||
async def add_download(self, user_id: int, video_id: str, title: str, file_path: str) -> Download:
|
|
||||||
"""Add download record"""
|
|
||||||
with self.get_session() as session:
|
|
||||||
download = Download(
|
|
||||||
user_id=user_id,
|
|
||||||
video_id=video_id,
|
|
||||||
title=title,
|
|
||||||
file_path=file_path
|
|
||||||
)
|
|
||||||
session.add(download)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(download)
|
|
||||||
return download
|
|
||||||
|
|
||||||
async def get_search_history(self, user_id: int, limit: int = 10) -> List[SearchHistory]:
|
|
||||||
"""Get user search history"""
|
|
||||||
with self.get_session() as session:
|
|
||||||
return session.query(SearchHistory)\
|
|
||||||
.filter(SearchHistory.user_id == user_id)\
|
|
||||||
.order_by(SearchHistory.created_at.desc())\
|
|
||||||
.limit(limit)\
|
|
||||||
.all()
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import os
|
|
||||||
import asyncio
|
|
||||||
import subprocess
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import httpx
|
|
||||||
import json
|
|
||||||
import vk_api
|
|
||||||
from vk_api.audio import VkAudio
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Track:
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
artist: str
|
|
||||||
duration: int
|
|
||||||
thumbnail: str
|
|
||||||
url: str
|
|
||||||
permalink: str
|
|
||||||
|
|
||||||
class VKMusicService:
|
|
||||||
def __init__(self):
|
|
||||||
self.vk_login = os.getenv("VK_LOGIN")
|
|
||||||
self.vk_password = os.getenv("VK_PASSWORD")
|
|
||||||
self.vk_session = None
|
|
||||||
self.vk_audio = None
|
|
||||||
self.client = httpx.AsyncClient()
|
|
||||||
|
|
||||||
if self.vk_login and self.vk_password:
|
|
||||||
try:
|
|
||||||
self.vk_session = vk_api.VkApi(self.vk_login, self.vk_password)
|
|
||||||
self.vk_session.auth()
|
|
||||||
self.vk_audio = VkAudio(self.vk_session)
|
|
||||||
print("✅ VK Music service initialized")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ VK Music initialization failed: {e}")
|
|
||||||
self.vk_session = None
|
|
||||||
self.vk_audio = None
|
|
||||||
else:
|
|
||||||
print("⚠️ VK_LOGIN or VK_PASSWORD not configured")
|
|
||||||
|
|
||||||
async def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
|
|
||||||
"""Search for tracks on VK Music"""
|
|
||||||
try:
|
|
||||||
if not self.vk_audio:
|
|
||||||
print("VK Audio not available, returning empty results")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Search for audio tracks
|
|
||||||
search_results = self.vk_audio.search(q=query, count=limit)
|
|
||||||
tracks = []
|
|
||||||
|
|
||||||
for item in search_results:
|
|
||||||
# VK Audio returns dict with keys: artist, title, duration, url, etc.
|
|
||||||
track = Track(
|
|
||||||
id=str(item.get('id', '')),
|
|
||||||
title=item.get('title', 'Unknown Title'),
|
|
||||||
artist=item.get('artist', 'Unknown Artist'),
|
|
||||||
duration=item.get('duration', 0),
|
|
||||||
thumbnail=item.get('album', {}).get('thumb', {}).get('photo_600', '') if item.get('album') else '',
|
|
||||||
url=item.get('url', ''),
|
|
||||||
permalink=item.get('url', '')
|
|
||||||
)
|
|
||||||
tracks.append(track)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"VK search error: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_audio_stream(self, track_id: str, track_url: str) -> Optional[bytes]:
|
|
||||||
"""Get audio stream for a track"""
|
|
||||||
try:
|
|
||||||
if not track_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Download audio stream directly from VK URL
|
|
||||||
response = await self.client.get(track_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Audio stream error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def download_track(self, track_url: str, output_path: str) -> bool:
|
|
||||||
"""Download track directly to file"""
|
|
||||||
try:
|
|
||||||
if not track_url:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Download directly from VK URL
|
|
||||||
response = await self.client.get(track_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Save to file
|
|
||||||
with open(output_path, 'wb') as f:
|
|
||||||
f.write(response.content)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Download track error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close HTTP client"""
|
|
||||||
await self.client.aclose()
|
|
||||||
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 (Python Backend)
|
|
||||||
quixotic-app:
|
quixotic-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -66,41 +29,34 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.docker
|
- .env.docker
|
||||||
environment:
|
environment:
|
||||||
PORT: 8000
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
PORT: 3000
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-quixotic}:${POSTGRES_PASSWORD:-quixotic123}@postgres:5432/${POSTGRES_DB:-quixotic}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-quixotic}:${POSTGRES_PASSWORD:-quixotic123}@postgres:5432/${POSTGRES_DB:-quixotic}
|
||||||
|
DATABASE_SSL: false
|
||||||
volumes:
|
volumes:
|
||||||
- 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.services.quixotic.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.quixotic-http.rule=Host(`localhost`)"
|
- "traefik.docker.network=traefik-global"
|
||||||
- "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=8000"
|
|
||||||
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
|
||||||
|
|||||||
87
eslint.config.mjs
Normal file
87
eslint.config.mjs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||||
|
import typescriptParser from '@typescript-eslint/parser';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["**/dist/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.js"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "commonjs",
|
||||||
|
globals: {
|
||||||
|
console: "readonly",
|
||||||
|
process: "readonly",
|
||||||
|
Buffer: "readonly",
|
||||||
|
__dirname: "readonly",
|
||||||
|
__filename: "readonly",
|
||||||
|
module: "readonly",
|
||||||
|
require: "readonly",
|
||||||
|
exports: "readonly",
|
||||||
|
global: "readonly",
|
||||||
|
setTimeout: "readonly",
|
||||||
|
clearTimeout: "readonly",
|
||||||
|
setInterval: "readonly",
|
||||||
|
clearInterval: "readonly",
|
||||||
|
URLSearchParams: "readonly",
|
||||||
|
window: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
fetch: "readonly",
|
||||||
|
event: "readonly"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"indent": ["error", 4],
|
||||||
|
"quotes": ["error", "single"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-undef": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: typescriptParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
project: ["./tsconfig.json", "./tsconfig.frontend.json"]
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
console: "readonly",
|
||||||
|
process: "readonly",
|
||||||
|
Buffer: "readonly",
|
||||||
|
__dirname: "readonly",
|
||||||
|
__filename: "readonly",
|
||||||
|
module: "readonly",
|
||||||
|
require: "readonly",
|
||||||
|
exports: "readonly",
|
||||||
|
global: "readonly",
|
||||||
|
setTimeout: "readonly",
|
||||||
|
clearTimeout: "readonly",
|
||||||
|
setInterval: "readonly",
|
||||||
|
clearInterval: "readonly",
|
||||||
|
URLSearchParams: "readonly",
|
||||||
|
window: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
fetch: "readonly",
|
||||||
|
event: "readonly"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": typescriptEslint
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"indent": ["error", 4],
|
||||||
|
"quotes": ["error", "single"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}],
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
29
main.py
29
main.py
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Quixotic Python Backend - Main Entry Point
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import uvicorn
|
|
||||||
from backend.api import app
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point"""
|
|
||||||
port = int(os.getenv("PORT", 8000))
|
|
||||||
host = os.getenv("HOST", "0.0.0.0")
|
|
||||||
|
|
||||||
print(f"🚀 Starting Quixotic Python Backend on {host}:{port}")
|
|
||||||
print(f"📁 Downloads directory: {os.path.abspath('downloads')}")
|
|
||||||
print(f"🌐 Access URL: http://localhost:{port}")
|
|
||||||
|
|
||||||
# Run the FastAPI app with uvicorn
|
|
||||||
uvicorn.run(
|
|
||||||
app,
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
log_level="info",
|
|
||||||
access_log=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
5299
package-lock.json
generated
Normal file
5299
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
package.json
Normal file
65
package.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "quixotic",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Telegram miniapp for YouTube music search and MP3 conversion",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/generate-version.js && tsc && tsc -p tsconfig.frontend.json",
|
||||||
|
"build:backend": "tsc",
|
||||||
|
"build:frontend": "tsc -p tsconfig.frontend.json",
|
||||||
|
"build:prod": "node scripts/generate-version.js && yarn build && node scripts/minify.js",
|
||||||
|
"minify": "node scripts/minify.js",
|
||||||
|
"version": "node scripts/generate-version.js",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"dev": "ts-node src/server.ts",
|
||||||
|
"dev:watch": "nodemon --exec ts-node src/server.ts",
|
||||||
|
"dev:bg": "nohup ts-node src/server.ts > server.log 2>&1 & echo $! > server.pid && echo 'Server started in background, PID saved to server.pid'",
|
||||||
|
"stop": "if [ -f server.pid ]; then kill $(cat server.pid) && rm server.pid && echo 'Server stopped'; else echo 'No PID file found'; fi",
|
||||||
|
"logs": "tail -f server.log",
|
||||||
|
"lint": "eslint src/ public/ --ext .ts,.js",
|
||||||
|
"lint:fix": "eslint src/ public/ --ext .ts,.js --fix",
|
||||||
|
"validate": "yarn lint && yarn build && echo '✅ All checks passed!'",
|
||||||
|
"pretest": "yarn validate",
|
||||||
|
"docker:build": "docker-compose build",
|
||||||
|
"docker:up": "docker-compose up -d",
|
||||||
|
"docker:down": "docker-compose down",
|
||||||
|
"docker:logs": "docker-compose logs -f",
|
||||||
|
"docker:restart": "docker-compose restart",
|
||||||
|
"docker:rebuild": "docker-compose down && docker-compose build --no-cache && docker-compose up -d",
|
||||||
|
"docker:dev": "docker-compose up --build",
|
||||||
|
"docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d",
|
||||||
|
"docker:prod:down": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml down"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@1.22.19",
|
||||||
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
|
"node-telegram-bot-api": "^0.64.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"soundcloud-downloader": "^1.0.0",
|
||||||
|
"winston": "^3.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"@types/node-telegram-bot-api": "^0.64.10",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
|
"eslint": "^9.34.0",
|
||||||
|
"html-minifier-terser": "^7.2.0",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"terser": "^5.44.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"axios": ">=0.30.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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,12 +191,18 @@
|
|||||||
id="searchInput"
|
id="searchInput"
|
||||||
placeholder="Название песни или исполнитель..."
|
placeholder="Название песни или исполнитель..."
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<button class="tg-input-clear" id="clearButton" style="display: none;" type="button">✕</button>
|
<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
|
||||||
|
|||||||
904
public/script.ts
904
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>
|
||||||
518
public/style.css
518
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);
|
||||||
@@ -113,31 +171,29 @@ body {
|
|||||||
|
|
||||||
.tg-input-clear {
|
.tg-input-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--tg-spacing-sm);
|
right: 12px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 32px;
|
background: none;
|
||||||
height: 32px;
|
|
||||||
background: var(--tg-color-hint);
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
color: var(--tg-color-hint);
|
||||||
color: var(--tg-color-bg);
|
padding: 8px;
|
||||||
font-size: var(--tg-font-size-sm);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0.6;
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-input-clear:hover {
|
.tg-input-clear:hover {
|
||||||
background: var(--tg-color-destructive);
|
background: var(--tg-color-secondary-bg);
|
||||||
opacity: 1;
|
color: var(--tg-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-input-clear:active {
|
.tg-input-clear:active {
|
||||||
transform: translateY(-50%) scale(0.95);
|
transform: translateY(-50%) scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button components */
|
/* Button components */
|
||||||
@@ -150,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;
|
||||||
@@ -207,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);
|
||||||
}
|
}
|
||||||
@@ -232,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 {
|
||||||
@@ -258,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;
|
||||||
@@ -278,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 */
|
||||||
@@ -327,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;
|
||||||
@@ -336,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 {
|
||||||
@@ -346,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;
|
||||||
}
|
}
|
||||||
@@ -385,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 {
|
||||||
@@ -421,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 {
|
||||||
@@ -433,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;
|
||||||
@@ -493,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 {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
fastapi==0.104.1
|
|
||||||
uvicorn[standard]==0.24.0
|
|
||||||
sqlalchemy==2.0.23
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
python-telegram-bot==20.7
|
|
||||||
aiofiles==23.2.1
|
|
||||||
python-multipart==0.0.6
|
|
||||||
pydantic==2.5.0
|
|
||||||
httpx==0.25.2
|
|
||||||
|
|
||||||
vk-api==11.9.9
|
|
||||||
requests==2.31.0
|
|
||||||
beautifulsoup4==4.12.2
|
|
||||||
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();
|
||||||
91
setup.py
91
setup.py
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Setup script for Quixotic Python Backend
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
def run_command(cmd, description):
|
|
||||||
"""Run a command and handle errors"""
|
|
||||||
print(f"📦 {description}...")
|
|
||||||
try:
|
|
||||||
subprocess.run(cmd, shell=True, check=True)
|
|
||||||
print(f"✅ {description} completed successfully")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ {description} failed: {e}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main setup function"""
|
|
||||||
print("🚀 Setting up Quixotic Python Backend...")
|
|
||||||
|
|
||||||
# Check Python version
|
|
||||||
if sys.version_info < (3, 8):
|
|
||||||
print("❌ Python 3.8 or higher is required")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"✅ Python {sys.version.split()[0]} detected")
|
|
||||||
|
|
||||||
# Create virtual environment if it doesn't exist
|
|
||||||
if not os.path.exists("venv"):
|
|
||||||
if not run_command("python -m venv venv", "Creating virtual environment"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Activate virtual environment and install dependencies
|
|
||||||
if os.name == 'nt': # Windows
|
|
||||||
activate_cmd = "venv\\Scripts\\activate && "
|
|
||||||
else: # Unix/Linux/Mac
|
|
||||||
activate_cmd = "source venv/bin/activate && "
|
|
||||||
|
|
||||||
if not run_command(f"{activate_cmd}pip install -r requirements.txt", "Installing Python dependencies"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create necessary directories
|
|
||||||
directories = ["downloads", "database"]
|
|
||||||
for directory in directories:
|
|
||||||
if not os.path.exists(directory):
|
|
||||||
os.makedirs(directory)
|
|
||||||
print(f"📁 Created directory: {directory}")
|
|
||||||
|
|
||||||
# Check for environment variables
|
|
||||||
print("\n🔧 Environment Setup:")
|
|
||||||
required_vars = [
|
|
||||||
("DATABASE_URL", "postgresql://user:password@localhost:5432/quixotic"),
|
|
||||||
("VK_LOGIN", "your_vk_login"),
|
|
||||||
("VK_PASSWORD", "your_vk_password"),
|
|
||||||
("TELEGRAM_BOT_TOKEN", "your_telegram_bot_token"),
|
|
||||||
("WEB_APP_URL", "http://localhost:8000")
|
|
||||||
]
|
|
||||||
|
|
||||||
env_file_content = []
|
|
||||||
|
|
||||||
for var_name, example in required_vars:
|
|
||||||
if not os.getenv(var_name):
|
|
||||||
print(f"⚠️ {var_name} not set (example: {example})")
|
|
||||||
env_file_content.append(f"{var_name}={example}")
|
|
||||||
else:
|
|
||||||
print(f"✅ {var_name} is configured")
|
|
||||||
|
|
||||||
# Create .env file if needed
|
|
||||||
if env_file_content and not os.path.exists(".env"):
|
|
||||||
with open(".env", "w") as f:
|
|
||||||
f.write("# Quixotic Python Backend Environment Variables\n")
|
|
||||||
f.write("# Copy this file and update with your actual values\n\n")
|
|
||||||
f.write("\n".join(env_file_content))
|
|
||||||
print("📝 Created .env file with example values")
|
|
||||||
|
|
||||||
print("\n🎉 Setup completed successfully!")
|
|
||||||
print("\n📋 Next steps:")
|
|
||||||
print("1. Update .env file with your actual credentials")
|
|
||||||
print("2. Set up PostgreSQL database")
|
|
||||||
print("3. Run: python main.py")
|
|
||||||
print("4. Access the API at: http://localhost:8000")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = main()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
512
src/bot.ts
Normal file
512
src/bot.ts
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { Database } from './database';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
interface TelegramUser {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
chat: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
from?: TelegramUser;
|
||||||
|
web_app?: {
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineQuery {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebAppData {
|
||||||
|
action: string;
|
||||||
|
audioUrl: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
query: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuixoticBot {
|
||||||
|
private bot: TelegramBot;
|
||||||
|
private webAppUrl: string;
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
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.webAppUrl = webAppUrl;
|
||||||
|
this.db = new Database();
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(): void {
|
||||||
|
logger.telegram('Bot initialized');
|
||||||
|
this.setupCommands();
|
||||||
|
this.setupHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCommands(): void {
|
||||||
|
// Set bot commands
|
||||||
|
this.bot.setMyCommands([
|
||||||
|
{ command: 'start', description: 'Запустить приложение' },
|
||||||
|
{ command: 'help', description: 'Помощь' },
|
||||||
|
{ command: 'history', description: 'История поиска' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers(): void {
|
||||||
|
logger.telegram('Setting up bot handlers...');
|
||||||
|
|
||||||
|
// Handle messages
|
||||||
|
this.bot.on('message', (msg: any) => {
|
||||||
|
|
||||||
|
// Handle web app data in regular message event
|
||||||
|
if (msg.web_app?.data) {
|
||||||
|
this.handleWebAppData(msg);
|
||||||
|
return; // Important: don't process as regular message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start command
|
||||||
|
this.bot.onText(/\/start/, async (msg: Message) => {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
const user = msg.from;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add user to database
|
||||||
|
if (user) {
|
||||||
|
await this.db.addUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboard = {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{
|
||||||
|
text: 'Открыть Quixotic',
|
||||||
|
web_app: { url: this.webAppUrl }
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'Добро пожаловать в Quixotic!\n\n' +
|
||||||
|
'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' +
|
||||||
|
'Нажми кнопку ниже, чтобы начать поиск:',
|
||||||
|
{ reply_markup: keyboard }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Start command error:', error);
|
||||||
|
await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Help command
|
||||||
|
this.bot.onText(/\/help/, async (msg: Message) => {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
|
||||||
|
const helpText = `*Quixotic - SoundCloud to MP3*
|
||||||
|
|
||||||
|
*Как пользоваться:*
|
||||||
|
1. Нажми кнопку "Открыть Quixotic"
|
||||||
|
2. Введи название песни в поисковую строку
|
||||||
|
3. Выбери нужный трек из списка
|
||||||
|
4. Получи MP3 файл в чат!
|
||||||
|
|
||||||
|
*Команды:*
|
||||||
|
/start - Запустить приложение
|
||||||
|
/help - Эта справка
|
||||||
|
/history - История поиска
|
||||||
|
|
||||||
|
*Возможности:*
|
||||||
|
- Поиск по SoundCloud
|
||||||
|
- Высокое качество MP3 (192kbps)
|
||||||
|
- Быстрая конвертация
|
||||||
|
- История поиска`;
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// History command
|
||||||
|
this.bot.onText(/\/history/, async (msg: Message) => {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
const userId = msg.from?.id;
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await this.db.getUserByTelegramId(userId);
|
||||||
|
if (!user) {
|
||||||
|
await this.bot.sendMessage(chatId, 'История поиска пуста.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent search history
|
||||||
|
const history = await this.getSearchHistory(user.id);
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
await this.bot.sendMessage(chatId, 'История поиска пуста.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let historyText = '*Последние поисковые запросы:*\n\n';
|
||||||
|
history.forEach((item, index) => {
|
||||||
|
const date = new Date(item.created_at).toLocaleDateString('ru-RU');
|
||||||
|
historyText += `${index + 1}. ${item.query} _(${date})_\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('History command error:', error);
|
||||||
|
await this.bot.sendMessage(chatId, 'Ошибка получения истории.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle web app data - primary event handler
|
||||||
|
this.bot.on('web_app_data', async (msg: Message) => {
|
||||||
|
this.handleWebAppData(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle callback queries
|
||||||
|
this.bot.on('callback_query', async (query: any) => {
|
||||||
|
if (query.data) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(query.data);
|
||||||
|
if (data.action === 'send_audio') {
|
||||||
|
await this.sendAudioFileInternal(query.message.chat.id, data.audioUrl, data.title);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.bot.answerCallbackQuery(query.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle inline queries for search
|
||||||
|
this.bot.on('inline_query', async (query: InlineQuery) => {
|
||||||
|
const queryId = query.id;
|
||||||
|
const searchQuery = query.query;
|
||||||
|
|
||||||
|
if (!searchQuery || searchQuery.length < 3) {
|
||||||
|
await this.bot.answerInlineQuery(queryId, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { SoundCloudService } = require('./soundcloud');
|
||||||
|
const soundcloud = new SoundCloudService();
|
||||||
|
const videos = await soundcloud.searchTracks(searchQuery, 5);
|
||||||
|
|
||||||
|
const results = videos.map((video: any, index: number) => ({
|
||||||
|
type: 'article',
|
||||||
|
id: `${index}`,
|
||||||
|
title: video.title,
|
||||||
|
description: `${video.channel} - ${this.formatDuration(video.duration)}`,
|
||||||
|
thumb_url: video.thumbnail,
|
||||||
|
input_message_content: {
|
||||||
|
message_text: `${video.title}\n${video.url}`
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.bot.answerInlineQuery(queryId, results, {
|
||||||
|
cache_time: 300,
|
||||||
|
is_personal: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Inline query error:', error);
|
||||||
|
await this.bot.answerInlineQuery(queryId, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler with detailed logging
|
||||||
|
this.bot.on('error', (error: any) => {
|
||||||
|
logger.error('Telegram bot error:', error.message || error);
|
||||||
|
logger.error('Error code:', error.code);
|
||||||
|
logger.error('Full error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle polling errors specifically
|
||||||
|
this.bot.on('polling_error', (error: any) => {
|
||||||
|
logger.error('Telegram polling error:', error.message || error);
|
||||||
|
logger.error('Error code:', error.code);
|
||||||
|
|
||||||
|
// Don't crash on polling errors, just log them
|
||||||
|
if (error.code === 'ETELEGRAM') {
|
||||||
|
logger.warn('Telegram API error - continuing operation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
logger.telegram('Bot handlers setup complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSearchHistory(userId: number): Promise<SearchResult[]> {
|
||||||
|
return this.db.getSearchHistory(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method for external API calls
|
||||||
|
public async sendAudioFile(chatId: number, audioUrl: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
|
||||||
|
logger.debug(`sendAudioFile called with performer: ${performer}, thumbnail: ${thumbnail}`);
|
||||||
|
return this.sendAudioFileInternal(chatId, audioUrl, title, performer, thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.telegram('Sending audio', `${title} to chat ${chatId}`);
|
||||||
|
logger.debug(`File source: ${audioUrlOrPath}`);
|
||||||
|
logger.debug(`Performer: ${performer || 'Not provided'}`);
|
||||||
|
logger.debug(`Thumbnail: ${thumbnail || 'Not provided'}`);
|
||||||
|
|
||||||
|
// Check if it's a URL or local file path
|
||||||
|
const isUrl = audioUrlOrPath.startsWith('http');
|
||||||
|
let filePath = audioUrlOrPath;
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
// Extract filename from URL and construct local path
|
||||||
|
const urlParts = audioUrlOrPath.split('/');
|
||||||
|
const filename = urlParts[urlParts.length - 1];
|
||||||
|
filePath = require('path').join(process.cwd(), 'downloads', filename);
|
||||||
|
logger.debug(`Converted URL to local path: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
logger.error(`File not found: ${filePath}`);
|
||||||
|
throw new Error('File not found: ' + filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file stats for debugging
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
logger.debug(`File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
// Generate custom filename for display
|
||||||
|
const safeTitle = (title || '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,
|
||||||
|
performer: performer || 'Unknown Artist',
|
||||||
|
caption: undefined,
|
||||||
|
parse_mode: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add thumbnail if downloaded
|
||||||
|
if (thumbnailPath) {
|
||||||
|
options.thumbnail = fs.createReadStream(thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.sendAudio(chatId, fileStream, options, {
|
||||||
|
filename: customFilename,
|
||||||
|
contentType: 'audio/mpeg'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`Audio sent successfully: ${title}`);
|
||||||
|
|
||||||
|
// Clean up thumbnail file
|
||||||
|
if (thumbnailPath) {
|
||||||
|
fs.unlink(thumbnailPath, (err: any) => {
|
||||||
|
if (err) logger.error('Failed to delete thumbnail:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Audio send failed:', error.message);
|
||||||
|
logger.error('Error code:', error.code);
|
||||||
|
|
||||||
|
// Clean up thumbnail file on error
|
||||||
|
if (thumbnailPath) {
|
||||||
|
fs.unlink(thumbnailPath, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try as document
|
||||||
|
try {
|
||||||
|
logger.info('Retrying as document...');
|
||||||
|
const docStream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
await this.bot.sendDocument(chatId, docStream, {
|
||||||
|
caption: `${title}\n${performer || 'Unknown Artist'}`,
|
||||||
|
parse_mode: undefined
|
||||||
|
}, {
|
||||||
|
filename: customFilename,
|
||||||
|
contentType: 'audio/mpeg'
|
||||||
|
});
|
||||||
|
logger.success(`Document sent successfully: ${title}`);
|
||||||
|
return;
|
||||||
|
|
||||||
|
} catch (documentError: any) {
|
||||||
|
logger.error('Document send also failed:', documentError.message);
|
||||||
|
throw documentError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Send failed completely:', error.message);
|
||||||
|
logger.error('Full error:', error);
|
||||||
|
|
||||||
|
// Send error message to user
|
||||||
|
try {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`Не удалось отправить файл.\n${title}\n\nПопробуйте другой трек.`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
logger.error('Could not even send error message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw to trigger unhandled rejection handler
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWebAppData(msg: Message): Promise<void> {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
|
||||||
|
if (!msg.web_app?.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: WebAppData = JSON.parse(msg.web_app.data);
|
||||||
|
|
||||||
|
if (data.action === 'send_audio') {
|
||||||
|
logger.telegram('WebApp request', data.title);
|
||||||
|
await this.sendAudioFileInternal(chatId, data.audioUrl, data.title);
|
||||||
|
}
|
||||||
|
} catch (parseError: any) {
|
||||||
|
logger.error('WebApp data parse error:', parseError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private formatDuration(seconds: number): string {
|
||||||
|
if (!seconds) return '';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize bot if this file is run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
logger.error('TELEGRAM_BOT_TOKEN environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
new QuixoticBot(token, webAppUrl);
|
||||||
|
}
|
||||||
132
src/database.ts
Normal file
132
src/database.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
interface TelegramUser {
|
||||||
|
id: number;
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
telegram_id: number;
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Database {
|
||||||
|
private pool: Pool;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const connectionString = process.env.DATABASE_URL || 'postgresql://quixotic:quixotic123@localhost:5432/quixotic';
|
||||||
|
this.pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false
|
||||||
|
});
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tablesExist = await this.pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'users'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!tablesExist.rows[0].exists) {
|
||||||
|
logger.info('Creating database tables...');
|
||||||
|
|
||||||
|
// Users table
|
||||||
|
await this.pool.query(`CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
telegram_id BIGINT UNIQUE NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// Search history table
|
||||||
|
await this.pool.query(`CREATE TABLE search_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// Downloaded files table
|
||||||
|
await this.pool.query(`CREATE TABLE downloads (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
file_path TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
logger.success('Database tables created successfully');
|
||||||
|
} else {
|
||||||
|
logger.info('Database tables already exist');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Database initialization error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUser(telegramUser: TelegramUser): Promise<number> {
|
||||||
|
const { id, username, first_name, last_name } = telegramUser;
|
||||||
|
const result = await this.pool.query(
|
||||||
|
`INSERT INTO users (telegram_id, username, first_name, last_name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name
|
||||||
|
RETURNING id`,
|
||||||
|
[id, username, first_name, last_name]
|
||||||
|
);
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userId: number, query: string): Promise<number> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'INSERT INTO search_history (user_id, query) VALUES ($1, $2) RETURNING id',
|
||||||
|
[userId, query]
|
||||||
|
);
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDownload(userId: number, trackId: string, title: string, filePath: string): Promise<number> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'INSERT INTO downloads (user_id, track_id, title, file_path) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||||
|
[userId, trackId, title, filePath]
|
||||||
|
);
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByTelegramId(telegramId: string | number): Promise<User | undefined> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'SELECT * FROM users WHERE telegram_id = $1',
|
||||||
|
[telegramId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchHistory(userId: number, limit: number = 10): Promise<{query: string, created_at: string}[]> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'SELECT query, created_at FROM search_history WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||||
|
[userId, limit]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
416
src/server.ts
Normal file
416
src/server.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
|
|
||||||
|
// Configure ffmpeg paths
|
||||||
|
ffmpeg.setFfmpegPath('/usr/bin/ffmpeg');
|
||||||
|
ffmpeg.setFfprobePath('/usr/bin/ffprobe');
|
||||||
|
import { Database } from './database';
|
||||||
|
import { SoundCloudService } from './soundcloud';
|
||||||
|
import { QuixoticBot } from './bot';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const db = new Database();
|
||||||
|
const soundcloud = new SoundCloudService();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(compression()); // Enable gzip compression
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req: Request, res: Response, next) => {
|
||||||
|
res.set('Content-Security-Policy',
|
||||||
|
'default-src \'self\'; ' +
|
||||||
|
'script-src \'self\' https://telegram.org \'unsafe-inline\'; ' +
|
||||||
|
'style-src \'self\' \'unsafe-inline\'; ' +
|
||||||
|
'img-src \'self\' data: https:; ' +
|
||||||
|
'font-src \'self\'; ' +
|
||||||
|
'media-src \'self\' blob: data:; ' +
|
||||||
|
'connect-src \'self\' https://telegram.org; ' +
|
||||||
|
'frame-ancestors \'self\'; ' +
|
||||||
|
'base-uri \'self\'; ' +
|
||||||
|
'form-action \'self\''
|
||||||
|
);
|
||||||
|
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||||
|
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||||
|
res.set('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimized caching strategy
|
||||||
|
app.use(express.static('public', {
|
||||||
|
maxAge: 0, // Don't cache by default, set specific headers below
|
||||||
|
etag: true,
|
||||||
|
lastModified: true,
|
||||||
|
setHeaders: (res: Response, filePath: string) => {
|
||||||
|
// Cache images, fonts, etc. with immutable flag
|
||||||
|
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|woff|woff2|ttf|eot|svg)$/)) {
|
||||||
|
res.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
// Cache CSS and JS with version string for 1 year (they have ?v= in URL)
|
||||||
|
else if (filePath.match(/\.(css|js)$/)) {
|
||||||
|
res.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
// HTML files - NO CACHE
|
||||||
|
else if (filePath.match(/\.html$/)) {
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
|
}
|
||||||
|
// JSON files (version.json) - NO CACHE
|
||||||
|
else if (filePath.match(/\.json$/)) {
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ensure downloads directory exists
|
||||||
|
const downloadsDir = path.join(__dirname, '../downloads');
|
||||||
|
if (!fs.existsSync(downloadsDir)) {
|
||||||
|
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load version for cache busting
|
||||||
|
let appVersion = Date.now().toString();
|
||||||
|
try {
|
||||||
|
const versionPath = path.join(__dirname, '../public/version.json');
|
||||||
|
if (fs.existsSync(versionPath)) {
|
||||||
|
const versionData = JSON.parse(fs.readFileSync(versionPath, 'utf8'));
|
||||||
|
appVersion = versionData.version || appVersion;
|
||||||
|
logger.info(`App version loaded: ${appVersion}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Could not load version file, using timestamp');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/', (req: Request, res: Response) => {
|
||||||
|
// Use minified HTML in production
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const htmlFile = isProduction ? 'index.min.html' : 'index.html';
|
||||||
|
const indexPath = path.join(__dirname, '../public', htmlFile);
|
||||||
|
|
||||||
|
// Set cache headers for HTML (no cache for HTML itself)
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read HTML and inject version
|
||||||
|
try {
|
||||||
|
let html = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
// Replace all version placeholders with actual version
|
||||||
|
html = html.replace(/\?v=(VERSION|\d+)/g, `?v=${appVersion}`);
|
||||||
|
res.send(html);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error serving HTML:', error);
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search videos
|
||||||
|
app.post('/api/search', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { query, userId, page }: { query?: string; userId?: string; page?: number } = req.body;
|
||||||
|
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Query is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset based on page number (10 results per page)
|
||||||
|
const currentPage = page || 1;
|
||||||
|
const resultsPerPage = 10;
|
||||||
|
const offset = (currentPage - 1) * resultsPerPage;
|
||||||
|
|
||||||
|
// Save search history
|
||||||
|
if (userId && userId !== 'demo') {
|
||||||
|
try {
|
||||||
|
const user = await db.getUserByTelegramId(userId);
|
||||||
|
if (user) {
|
||||||
|
await db.addSearchHistory(user.id, query);
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.error('Database error:', dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset);
|
||||||
|
|
||||||
|
// Return hasMore flag based on results
|
||||||
|
const hasMore = videos.length === resultsPerPage;
|
||||||
|
|
||||||
|
res.json({ videos, hasMore });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Search error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to search videos' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert video to MP3
|
||||||
|
app.post('/api/convert', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { videoId, title, userId, url, performer }: { videoId?: string; title?: string; userId?: string; url?: string; performer?: string } = req.body;
|
||||||
|
logger.info(`Convert request received: ${title} by ${performer || 'Unknown'} (ID: ${videoId})`);
|
||||||
|
|
||||||
|
if (!videoId) {
|
||||||
|
return res.status(400).json({ error: 'Video ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate safe filename
|
||||||
|
const safeTitle = (title || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50);
|
||||||
|
const filename = `${videoId}_${safeTitle}.mp3`;
|
||||||
|
const outputPath = path.join(downloadsDir, filename);
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (fs.existsSync(outputPath)) {
|
||||||
|
logger.info('File already exists, serving cached version');
|
||||||
|
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
||||||
|
return res.json({ audioUrl, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting MP3 conversion: ${title}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get audio stream from YouTube
|
||||||
|
logger.debug(`Attempting to get audio stream for: ${videoId}`);
|
||||||
|
const audioStream = await soundcloud.getAudioStream(videoId, url);
|
||||||
|
logger.info('Audio stream obtained, starting FFmpeg conversion...');
|
||||||
|
|
||||||
|
// Download to temporary file first, then convert
|
||||||
|
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
|
||||||
|
|
||||||
|
// Save stream to temporary file
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const writeStream = fs.createWriteStream(tempInputPath);
|
||||||
|
audioStream.pipe(writeStream);
|
||||||
|
audioStream.on('end', resolve);
|
||||||
|
audioStream.on('error', reject);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Temporary file saved, starting FFmpeg conversion...');
|
||||||
|
|
||||||
|
// Debug: check temp file
|
||||||
|
const stats = fs.statSync(tempInputPath);
|
||||||
|
logger.debug(`Temp file size: ${stats.size} bytes`);
|
||||||
|
|
||||||
|
// Test ffmpeg with simple command first
|
||||||
|
try {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' });
|
||||||
|
logger.debug('FFmpeg file test passed');
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('FFmpeg file test failed:', e.stderr || e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert temporary file to MP3 using ffmpeg
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const conversion = ffmpeg(tempInputPath)
|
||||||
|
.audioCodec('libmp3lame')
|
||||||
|
.audioBitrate('192k')
|
||||||
|
.audioChannels(2)
|
||||||
|
.audioFrequency(44100)
|
||||||
|
.format('mp3')
|
||||||
|
.output(outputPath)
|
||||||
|
.on('start', (command: string) => {
|
||||||
|
logger.ffmpeg('Started', command);
|
||||||
|
})
|
||||||
|
.on('progress', (progress: any) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
logger.success('MP3 conversion completed successfully');
|
||||||
|
// Clean up temporary file
|
||||||
|
fs.unlink(tempInputPath, (err) => {
|
||||||
|
if (err) logger.error('Failed to delete temp file:', err);
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.on('error', (err: Error) => {
|
||||||
|
logger.error('FFmpeg error:', err.message);
|
||||||
|
// Clean up temporary file on error
|
||||||
|
fs.unlink(tempInputPath, () => {});
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
conversion.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save download record
|
||||||
|
if (userId && userId !== 'demo') {
|
||||||
|
try {
|
||||||
|
const user = await db.getUserByTelegramId(userId);
|
||||||
|
if (user) {
|
||||||
|
await db.addDownload(user.id, videoId, title || '', outputPath);
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.error('Database error:', dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
||||||
|
logger.success(`Conversion successful: ${audioUrl}`);
|
||||||
|
res.json({ audioUrl, title });
|
||||||
|
|
||||||
|
} catch (conversionError: any) {
|
||||||
|
logger.error(`Conversion failed for video: ${videoId}`);
|
||||||
|
logger.error('Error details:', conversionError.message);
|
||||||
|
logger.error('Full error:', conversionError);
|
||||||
|
|
||||||
|
// Return error - no fallbacks for Telegram bot
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'MP3 conversion failed. This video may be restricted or unavailable for download.',
|
||||||
|
details: conversionError.message,
|
||||||
|
videoId: videoId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Server error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to process request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct Telegram API for sending audio
|
||||||
|
app.post('/api/telegram-send', async (req: Request, res: Response) => {
|
||||||
|
logger.telegram('Send request received');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body;
|
||||||
|
logger.telegram('Sending to user', `${userId}: ${title}`);
|
||||||
|
|
||||||
|
if (!userId || !audioUrl || !title) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const botInstance = (global as any).quixoticBot;
|
||||||
|
if (!botInstance) {
|
||||||
|
logger.error('Bot not available');
|
||||||
|
return res.status(500).json({ error: 'Bot not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatId = parseInt(userId);
|
||||||
|
await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail);
|
||||||
|
logger.success('Audio sent successfully');
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Audio sent successfully' });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Send failed:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve download files
|
||||||
|
app.use('/downloads', express.static(downloadsDir));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Version endpoint for client-side cache busting
|
||||||
|
app.get('/api/version', (req: Request, res: Response) => {
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
try {
|
||||||
|
const versionPath = path.join(__dirname, '../public/version.json');
|
||||||
|
if (fs.existsSync(versionPath)) {
|
||||||
|
const versionData = fs.readFileSync(versionPath, 'utf8');
|
||||||
|
res.json(JSON.parse(versionData));
|
||||||
|
} else {
|
||||||
|
res.json({ version: appVersion, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ version: appVersion, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: Error, _req: Request, res: Response, _next: any) => {
|
||||||
|
logger.error(err.stack || err.message);
|
||||||
|
res.status(500).json({ error: 'Something went wrong!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup old files periodically (every hour)
|
||||||
|
setInterval(() => {
|
||||||
|
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
fs.readdir(downloadsDir, (err, files) => {
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const filePath = path.join(downloadsDir, file);
|
||||||
|
fs.stat(filePath, (err, stats) => {
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
if (now - stats.mtime.getTime() > maxAge) {
|
||||||
|
fs.unlink(filePath, (err) => {
|
||||||
|
if (!err) {
|
||||||
|
logger.info('Deleted old file:', file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 60 * 60 * 1000); // Run every hour
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.success(`Quixotic server running on port ${port}`);
|
||||||
|
logger.info(`Downloads directory: ${downloadsDir}`);
|
||||||
|
logger.info(`Open in browser: http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Telegram bot
|
||||||
|
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const webAppUrl = process.env.WEB_APP_URL || `http://localhost:${port}`;
|
||||||
|
|
||||||
|
if (botToken && botToken.length > 10 && botToken !== 'your_telegram_bot_token_here') {
|
||||||
|
try {
|
||||||
|
const botInstance = new QuixoticBot(botToken, webAppUrl);
|
||||||
|
// Store bot instance globally for API access
|
||||||
|
(global as any).quixoticBot = botInstance;
|
||||||
|
logger.telegram('Bot started and stored globally');
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Bot initialization failed:', error.message);
|
||||||
|
logger.warn('Bot disabled due to error');
|
||||||
|
logger.warn('Telegram integration will not be available');
|
||||||
|
// Don't crash the server, continue without bot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
210
src/soundcloud.ts
Normal file
210
src/soundcloud.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import scdl from 'soundcloud-downloader';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
interface SearchTrack {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
user?: {
|
||||||
|
username: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
|
artwork_url?: string;
|
||||||
|
duration: number;
|
||||||
|
permalink_url: string;
|
||||||
|
streamable: boolean;
|
||||||
|
downloadable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackResult {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
channel: string;
|
||||||
|
thumbnail: string;
|
||||||
|
duration: number;
|
||||||
|
url: string;
|
||||||
|
streamable: boolean;
|
||||||
|
downloadable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackInfo {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
length: number;
|
||||||
|
available: boolean;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SoundCloudService {
|
||||||
|
constructor() {
|
||||||
|
logger.soundcloud('Service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHighQualityThumbnail(originalUrl: string): string {
|
||||||
|
if (!originalUrl) {
|
||||||
|
return 'https://via.placeholder.com/500x500?text=No+Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoundCloud provides different thumbnail sizes by changing the URL suffix:
|
||||||
|
// -large (100x100) -> -t500x500 (500x500) or -t300x300 (300x300)
|
||||||
|
// Try to get the highest quality version available
|
||||||
|
|
||||||
|
let highQualityUrl = originalUrl;
|
||||||
|
|
||||||
|
if (originalUrl.includes('-large.')) {
|
||||||
|
// Replace -large with -t500x500 for better quality
|
||||||
|
highQualityUrl = originalUrl.replace('-large.', '-t500x500.');
|
||||||
|
} else if (originalUrl.includes('-crop.')) {
|
||||||
|
// If it's crop (400x400), try to get t500x500 or keep crop
|
||||||
|
highQualityUrl = originalUrl.replace('-crop.', '-t500x500.');
|
||||||
|
} else if (originalUrl.includes('-t300x300.')) {
|
||||||
|
// If it's already 300x300, try to upgrade to 500x500
|
||||||
|
highQualityUrl = originalUrl.replace('-t300x300.', '-t500x500.');
|
||||||
|
} else if (originalUrl.includes('default_avatar_large.png')) {
|
||||||
|
// For default avatars, use a higher quality placeholder
|
||||||
|
highQualityUrl = 'https://via.placeholder.com/500x500/007AFF/ffffff?text=🎵';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log transformation if changed
|
||||||
|
if (highQualityUrl !== originalUrl) {
|
||||||
|
logger.debug(`Thumbnail upgraded: ${originalUrl.substring(0, 60)}... -> ${highQualityUrl.substring(0, 60)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no size suffix found or already high quality, return original
|
||||||
|
return highQualityUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchTracks(query: string, maxResults: number = 10, offset: number = 0): Promise<TrackResult[]> {
|
||||||
|
try {
|
||||||
|
logger.soundcloud('Searching', `${query} (offset: ${offset})`);
|
||||||
|
|
||||||
|
// Search for tracks on SoundCloud
|
||||||
|
const searchResult = await scdl.search({
|
||||||
|
query: query,
|
||||||
|
limit: maxResults,
|
||||||
|
offset: offset,
|
||||||
|
resourceType: 'tracks'
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
// Handle different response formats
|
||||||
|
let tracks: any[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(searchResult)) {
|
||||||
|
tracks = searchResult;
|
||||||
|
} else if (searchResult && searchResult.collection && Array.isArray(searchResult.collection)) {
|
||||||
|
tracks = searchResult.collection;
|
||||||
|
} else if (searchResult && searchResult.tracks && Array.isArray(searchResult.tracks)) {
|
||||||
|
tracks = searchResult.tracks;
|
||||||
|
} else if (searchResult && typeof searchResult === 'object') {
|
||||||
|
// Try to find any array property that might contain tracks
|
||||||
|
const keys = Object.keys(searchResult);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(searchResult[key]) && searchResult[key].length > 0) {
|
||||||
|
const firstItem = searchResult[key][0];
|
||||||
|
if (firstItem && (firstItem.id || firstItem.title || firstItem.permalink_url)) {
|
||||||
|
tracks = searchResult[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
logger.warn('No tracks found');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackResults: TrackResult[] = tracks.map(track => ({
|
||||||
|
id: track.id,
|
||||||
|
title: track.title,
|
||||||
|
channel: track.user?.username || 'Unknown Artist',
|
||||||
|
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || ''),
|
||||||
|
duration: Math.floor(track.duration / 1000) || 0, // Convert from ms to seconds
|
||||||
|
url: track.permalink_url,
|
||||||
|
streamable: track.streamable,
|
||||||
|
downloadable: track.downloadable
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.success(`Found ${trackResults.length} tracks on SoundCloud`);
|
||||||
|
return trackResults;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('SoundCloud search error:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrackInfo(trackId: string | number): Promise<TrackInfo> {
|
||||||
|
try {
|
||||||
|
const track = await scdl.getInfo(String(trackId)) as SearchTrack;
|
||||||
|
return {
|
||||||
|
title: track.title,
|
||||||
|
author: track.user?.username || 'Unknown',
|
||||||
|
length: Math.floor(track.duration / 1000),
|
||||||
|
available: track.streamable,
|
||||||
|
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '')
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting track info:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> {
|
||||||
|
try {
|
||||||
|
logger.soundcloud('Getting audio stream', `track: ${trackId}`);
|
||||||
|
|
||||||
|
// If trackUrl is provided, use it directly
|
||||||
|
if (trackUrl) {
|
||||||
|
logger.debug(`Using provided track URL: ${trackUrl}`);
|
||||||
|
const stream = await scdl.download(trackUrl);
|
||||||
|
logger.success('Audio stream obtained successfully from SoundCloud using URL');
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track info first if no URL provided
|
||||||
|
const trackInfo = await scdl.getInfo(String(trackId)) as SearchTrack;
|
||||||
|
|
||||||
|
if (!trackInfo.streamable) {
|
||||||
|
throw new Error('Track is not streamable');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Track: ${trackInfo.title}`);
|
||||||
|
logger.debug(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
|
||||||
|
logger.debug(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
|
||||||
|
|
||||||
|
// Use the permalink_url from track info
|
||||||
|
const stream = await scdl.download(trackInfo.permalink_url);
|
||||||
|
|
||||||
|
logger.success('Audio stream obtained successfully from SoundCloud');
|
||||||
|
return stream;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('SoundCloud download failed:', error.message);
|
||||||
|
|
||||||
|
// Try alternative approaches
|
||||||
|
try {
|
||||||
|
logger.info('Trying alternative SoundCloud methods...');
|
||||||
|
|
||||||
|
// Try with track ID directly
|
||||||
|
const stream = await scdl.download(String(trackId));
|
||||||
|
logger.success('Audio stream obtained with track ID method');
|
||||||
|
return stream;
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error('Track ID method failed, trying URL construction...');
|
||||||
|
|
||||||
|
// Final fallback - try constructing different URL formats
|
||||||
|
try {
|
||||||
|
const trackUrl = `https://soundcloud.com/${trackId}`;
|
||||||
|
const stream = await scdl.download(trackUrl);
|
||||||
|
logger.success('Audio stream obtained with constructed URL method');
|
||||||
|
return stream;
|
||||||
|
} catch (finalError: any) {
|
||||||
|
logger.error('All methods failed:', finalError.message);
|
||||||
|
throw new Error(`SoundCloud download failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple test server for frontend testing
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
import aiofiles
|
|
||||||
|
|
||||||
app = FastAPI(title="Quixotic Test API", version="1.0.0")
|
|
||||||
|
|
||||||
# Get public directory
|
|
||||||
public_dir = Path(__file__).parent / "public"
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def root():
|
|
||||||
"""Serve main HTML page with cache-busting"""
|
|
||||||
try:
|
|
||||||
index_path = public_dir / "index.html"
|
|
||||||
|
|
||||||
async with aiofiles.open(index_path, 'r', encoding='utf8') as f:
|
|
||||||
html = await f.read()
|
|
||||||
|
|
||||||
# Add timestamp for cache busting
|
|
||||||
timestamp = int(datetime.now().timestamp() * 1000)
|
|
||||||
html = html.replace('dist/script.js?v=2', f'dist/script.js?v={timestamp}')
|
|
||||||
|
|
||||||
return HTMLResponse(
|
|
||||||
content=html,
|
|
||||||
headers={
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to serve index: {str(e)}")
|
|
||||||
|
|
||||||
@app.get("/style.css")
|
|
||||||
async def get_css():
|
|
||||||
"""Serve CSS file"""
|
|
||||||
css_path = public_dir / "style.css"
|
|
||||||
if css_path.exists():
|
|
||||||
return FileResponse(
|
|
||||||
path=css_path,
|
|
||||||
media_type="text/css",
|
|
||||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=404, detail="CSS not found")
|
|
||||||
|
|
||||||
@app.get("/dist/script.js")
|
|
||||||
async def get_js():
|
|
||||||
"""Serve JavaScript file"""
|
|
||||||
js_path = public_dir / "dist" / "script.js"
|
|
||||||
if js_path.exists():
|
|
||||||
return FileResponse(
|
|
||||||
path=js_path,
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=404, detail="JavaScript not found")
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"message": "Test server running"
|
|
||||||
}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
port = int(os.getenv("PORT", 8000))
|
|
||||||
print(f"🚀 Starting test server on http://localhost:{port}")
|
|
||||||
print(f"📁 Public directory: {public_dir.absolute()}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
||||||
25
tsconfig.frontend.json
Normal file
25
tsconfig.frontend.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"outDir": "./public/dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"public/script.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"src",
|
||||||
|
"public/dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user