python
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend package
|
||||
BIN
backend/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/api.cpython-313.pyc
Normal file
BIN
backend/__pycache__/api.cpython-313.pyc
Normal file
Binary file not shown.
336
backend/api.py
Normal file
336
backend/api.py
Normal file
@@ -0,0 +1,336 @@
|
||||
import os
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
import aiofiles
|
||||
|
||||
from .database import Database, User
|
||||
from .vkmusic import VKMusicService, Track
|
||||
from .bot import QuixoticBot
|
||||
|
||||
app = FastAPI(title="Quixotic API", version="1.0.0")
|
||||
|
||||
# Initialize services
|
||||
db = Database()
|
||||
vkmusic = VKMusicService()
|
||||
bot_instance = None
|
||||
|
||||
# Ensure downloads directory exists
|
||||
downloads_dir = Path(__file__).parent.parent / "downloads"
|
||||
downloads_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Request/Response models
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
userId: Optional[str] = None
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
videos: List[Track]
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
videoId: str
|
||||
title: Optional[str] = None
|
||||
userId: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
performer: Optional[str] = None
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
audioUrl: str
|
||||
title: Optional[str] = None
|
||||
|
||||
class TelegramSendRequest(BaseModel):
|
||||
userId: str
|
||||
audioUrl: str
|
||||
title: str
|
||||
performer: Optional[str] = None
|
||||
thumbnail: Optional[str] = None
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
timestamp: str
|
||||
|
||||
# Routes
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Serve main HTML page with cache-busting"""
|
||||
try:
|
||||
public_dir = Path(__file__).parent.parent / "public"
|
||||
index_path = public_dir / "index.html"
|
||||
|
||||
async with aiofiles.open(index_path, 'r', encoding='utf8') as f:
|
||||
html = await f.read()
|
||||
|
||||
# Add timestamp for cache busting
|
||||
timestamp = int(datetime.now().timestamp() * 1000)
|
||||
html = html.replace('dist/script.js?v=2', f'dist/script.js?v={timestamp}')
|
||||
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to serve index: {str(e)}")
|
||||
|
||||
@app.post("/api/search", response_model=SearchResponse)
|
||||
async def search_videos(request: SearchRequest):
|
||||
"""Search for videos/tracks"""
|
||||
try:
|
||||
if not request.query or not request.query.strip():
|
||||
raise HTTPException(status_code=400, detail="Query is required")
|
||||
|
||||
# Save search history
|
||||
if request.userId and request.userId != 'demo':
|
||||
try:
|
||||
user = await db.get_user_by_telegram_id(request.userId)
|
||||
if user:
|
||||
await db.add_search_history(user.id, request.query)
|
||||
except Exception as db_error:
|
||||
print(f"Database error: {db_error}")
|
||||
|
||||
# Search for tracks
|
||||
videos = await vkmusic.search_tracks(request.query.strip())
|
||||
return SearchResponse(videos=videos)
|
||||
|
||||
except Exception as error:
|
||||
print(f"Search error: {error}")
|
||||
raise HTTPException(status_code=500, detail="Failed to search videos")
|
||||
|
||||
@app.post("/api/convert", response_model=ConvertResponse)
|
||||
async def convert_video(request: ConvertRequest, http_request: Request):
|
||||
"""Convert video to MP3"""
|
||||
try:
|
||||
if not request.videoId:
|
||||
raise HTTPException(status_code=400, detail="Video ID is required")
|
||||
|
||||
# Generate safe filename
|
||||
safe_title = "".join(c for c in (request.title or "") if c.isalnum() or c in (' ', '-', '_')).replace(' ', '_')[:50]
|
||||
filename = f"{request.videoId}_{safe_title}.mp3"
|
||||
output_path = downloads_dir / filename
|
||||
|
||||
# Check if file already exists
|
||||
if output_path.exists():
|
||||
print("File already exists, serving cached version")
|
||||
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
|
||||
audio_url = f"{base_url}/downloads/{filename}"
|
||||
return ConvertResponse(audioUrl=audio_url, title=request.title)
|
||||
|
||||
print(f"Starting MP3 conversion for: {request.title}")
|
||||
|
||||
# Download and convert using VK music service
|
||||
if not request.url:
|
||||
raise HTTPException(status_code=400, detail="Track URL is required")
|
||||
|
||||
success = await vkmusic.download_track(request.url, str(output_path))
|
||||
|
||||
if not success:
|
||||
# Fallback: try using FFmpeg conversion
|
||||
audio_data = await vkmusic.get_audio_stream(request.videoId, request.url)
|
||||
if not audio_data:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="MP3 conversion failed. This track may be restricted or unavailable for download."
|
||||
)
|
||||
|
||||
# Convert using FFmpeg
|
||||
await convert_with_ffmpeg(audio_data, output_path)
|
||||
|
||||
# Save download record
|
||||
if request.userId and request.userId != 'demo':
|
||||
try:
|
||||
user = await db.get_user_by_telegram_id(request.userId)
|
||||
if user:
|
||||
await db.add_download(user.id, request.videoId, request.title or "", str(output_path))
|
||||
except Exception as db_error:
|
||||
print(f"Database error: {db_error}")
|
||||
|
||||
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
|
||||
audio_url = f"{base_url}/downloads/{filename}"
|
||||
print(f"Conversion successful, file available at: {audio_url}")
|
||||
|
||||
return ConvertResponse(audioUrl=audio_url, title=request.title)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
print(f"Server error: {error}")
|
||||
raise HTTPException(status_code=500, detail="Failed to process request")
|
||||
|
||||
@app.post("/api/telegram-send")
|
||||
async def telegram_send(request: TelegramSendRequest):
|
||||
"""Send audio via Telegram bot"""
|
||||
print("🚀 Telegram send request received")
|
||||
|
||||
try:
|
||||
if not request.userId or not request.audioUrl or not request.title:
|
||||
raise HTTPException(status_code=400, detail="Missing required fields")
|
||||
|
||||
if not bot_instance:
|
||||
raise HTTPException(status_code=500, detail="Bot not available")
|
||||
|
||||
print(f"📤 Sending to user {request.userId}: {request.title}")
|
||||
|
||||
chat_id = int(request.userId)
|
||||
await bot_instance.send_audio_file(
|
||||
chat_id,
|
||||
request.audioUrl,
|
||||
request.title,
|
||||
request.performer,
|
||||
request.thumbnail
|
||||
)
|
||||
|
||||
print("✅ Audio sent successfully")
|
||||
return {"success": True, "message": "Audio sent successfully"}
|
||||
|
||||
except Exception as error:
|
||||
print(f"❌ Send failed: {error}")
|
||||
raise HTTPException(status_code=500, detail=str(error))
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
# Serve download files
|
||||
app.mount("/downloads", StaticFiles(directory=str(downloads_dir)), name="downloads")
|
||||
|
||||
# Serve static files
|
||||
public_dir = Path(__file__).parent.parent / "public"
|
||||
|
||||
@app.get("/style.css")
|
||||
async def get_css():
|
||||
"""Serve CSS file"""
|
||||
css_path = public_dir / "style.css"
|
||||
if css_path.exists():
|
||||
return FileResponse(
|
||||
path=css_path,
|
||||
media_type="text/css",
|
||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="CSS not found")
|
||||
|
||||
@app.get("/dist/script.js")
|
||||
async def get_js():
|
||||
"""Serve JavaScript file"""
|
||||
js_path = public_dir / "dist" / "script.js"
|
||||
if js_path.exists():
|
||||
return FileResponse(
|
||||
path=js_path,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache, max-age=0"}
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="JavaScript not found")
|
||||
|
||||
# Mount remaining static files
|
||||
if public_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(public_dir)), name="static")
|
||||
|
||||
async def convert_with_ffmpeg(audio_data: bytes, output_path: Path):
|
||||
"""Convert audio data to MP3 using FFmpeg"""
|
||||
temp_input = output_path.with_suffix('.tmp')
|
||||
|
||||
try:
|
||||
# Write audio data to temporary file
|
||||
async with aiofiles.open(temp_input, 'wb') as f:
|
||||
await f.write(audio_data)
|
||||
|
||||
# Convert using FFmpeg
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-i', str(temp_input),
|
||||
'-codec:a', 'libmp3lame',
|
||||
'-b:a', '192k',
|
||||
'-ac', '2',
|
||||
'-ar', '44100',
|
||||
'-f', 'mp3',
|
||||
str(output_path),
|
||||
'-y' # Overwrite output file
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
print(f"FFmpeg error: {stderr.decode()}")
|
||||
raise Exception("FFmpeg conversion failed")
|
||||
|
||||
print("MP3 conversion completed successfully")
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if temp_input.exists():
|
||||
temp_input.unlink()
|
||||
|
||||
# Cleanup task for old files
|
||||
async def cleanup_old_files():
|
||||
"""Clean up old download files"""
|
||||
max_age = 24 * 60 * 60 # 24 hours in seconds
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
try:
|
||||
for file_path in downloads_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
file_age = now - file_path.stat().st_mtime
|
||||
if file_age > max_age:
|
||||
file_path.unlink()
|
||||
print(f"Deleted old file: {file_path.name}")
|
||||
except Exception as e:
|
||||
print(f"Cleanup error: {e}")
|
||||
|
||||
# Startup event
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize services on startup"""
|
||||
global bot_instance
|
||||
|
||||
print("Starting Quixotic Python API...")
|
||||
|
||||
# Initialize Telegram bot
|
||||
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
web_app_url = os.getenv("WEB_APP_URL", "http://localhost:8000")
|
||||
|
||||
if bot_token and len(bot_token) > 10:
|
||||
try:
|
||||
bot_instance = QuixoticBot(bot_token, web_app_url, db)
|
||||
print("🤖 Telegram bot initialized")
|
||||
except Exception as error:
|
||||
print(f"❌ Bot initialization failed: {error}")
|
||||
print("⚠️ Bot disabled due to error")
|
||||
else:
|
||||
print("⚠️ TELEGRAM_BOT_TOKEN not found or invalid - bot will not start")
|
||||
|
||||
# Start cleanup task
|
||||
asyncio.create_task(periodic_cleanup())
|
||||
|
||||
async def periodic_cleanup():
|
||||
"""Periodic cleanup task"""
|
||||
while True:
|
||||
await asyncio.sleep(3600) # Run every hour
|
||||
await cleanup_old_files()
|
||||
|
||||
# Shutdown event
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Cleanup on shutdown"""
|
||||
await vkmusic.close()
|
||||
db.close()
|
||||
if bot_instance:
|
||||
await bot_instance.close()
|
||||
print("Quixotic API shutdown complete")
|
||||
163
backend/bot.py
Normal file
163
backend/bot.py
Normal file
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
135
backend/database.py
Normal file
135
backend/database.py
Normal file
@@ -0,0 +1,135 @@
|
||||
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()
|
||||
110
backend/vkmusic.py
Normal file
110
backend/vkmusic.py
Normal file
@@ -0,0 +1,110 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user