Compare commits

13 Commits
python ... main

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

View File

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

View File

@@ -20,6 +20,3 @@ 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
View File

@@ -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

Binary file not shown.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

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

View File

@@ -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`

View File

@@ -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

View File

@@ -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"]

View File

@@ -1 +0,0 @@
# Backend package

View File

@@ -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")

View File

@@ -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")

View File

@@ -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()

View File

@@ -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
View File

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

View File

@@ -1,40 +1,4 @@
services: 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
View 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
View File

@@ -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

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

22
public/manifest.json Normal file
View File

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

View File

@@ -1,12 +1,6 @@
# Allow search engines # 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

File diff suppressed because it is too large Load Diff

10
public/sitemap.xml Normal file
View File

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

View File

@@ -22,13 +22,24 @@
--tg-spacing-xl: 20px; --tg-spacing-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 {

View File

@@ -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

View File

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

127
scripts/minify.js Executable file
View File

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

View File

@@ -1,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
View 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\опробуйте другой трек.`
);
} 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
View 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
View File

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

416
src/server.ts Normal file
View 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
View 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}`);
}
}
}
}
}

View File

@@ -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
View 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
View 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"
]
}

3335
yarn.lock Normal file

File diff suppressed because it is too large Load Diff