geocrop-platform./apps/musicseerr/main.py

459 lines
16 KiB
Python

import os
import uuid
import logging
from fastapi import FastAPI, BackgroundTasks, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
import requests
from tasks import run_download_task
# Configure Logger
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("musicseerr-main")
app = FastAPI(title="MusicSeerr Ingestion Pipeline", version="1.0.0")
# In-memory jobs database
JOBS = {}
# Read configurations from Environment Variables
PROXY_URL = os.getenv("GLUETUN_PROXY_URL", "http://gluetun-svc:8888")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
class SubmitRequest(BaseModel):
query: str
def send_telegram_message(chat_id: int, text: str):
"""
Sends a message back to Telegram. Outgoing traffic uses the normal cluster network.
"""
if not TELEGRAM_BOT_TOKEN or not chat_id:
return
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {"chat_id": chat_id, "text": text}
try:
requests.post(url, json=payload, timeout=10)
except Exception as e:
logger.error(f"Failed to send Telegram message to chat {chat_id}: {e}")
@app.get("/health")
def health_check():
return {"status": "healthy", "proxy": PROXY_URL}
@app.get("/jobs")
def get_jobs():
"""
Returns the list of all jobs, sorted by timestamp.
"""
return JSONResponse(content=list(JOBS.values()))
@app.get("/status/{task_id}")
def get_job_status(task_id: str):
"""
Returns the status of a specific job.
"""
if task_id not in JOBS:
return JSONResponse(status_code=404, content={"error": "Job not found"})
return JSONResponse(content=JOBS[task_id])
@app.post("/submit")
def submit_job(req: SubmitRequest, background_tasks: BackgroundTasks):
"""
API endpoint to submit download jobs via JSON.
"""
task_id = str(uuid.uuid4())
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"error": "Query cannot be empty"})
JOBS[task_id] = {
"id": task_id,
"query": query,
"status": "pending",
"priority_used": None,
"files": [],
"error": None
}
background_tasks.add_task(run_download_task, task_id, query, PROXY_URL, JOBS)
return {"task_id": task_id, "status": "pending"}
@app.post("/telegram-webhook")
async def telegram_webhook(request: Request, background_tasks: BackgroundTasks):
"""
Endpoint for Telegram bot webhooks.
"""
try:
payload = await request.json()
logger.info(f"Received Telegram Webhook Payload: {payload}")
message = payload.get("message", {})
chat = message.get("chat", {})
chat_id = chat.get("id")
text = message.get("text", "").strip()
if not text or not chat_id:
return {"status": "ignored"}
# Ignore commands like /start unless they have arguments
if text.startswith("/start"):
parts = text.split(" ", 1)
if len(parts) > 1:
text = parts[1].strip()
else:
send_telegram_message(
chat_id,
"Welcome to MusicSeerr! 🎵\nSend me a song name, Spotify URL, YouTube URL, or Tubidy link, and I'll ingest it into the Navidrome library!"
)
return {"status": "welcomed"}
task_id = str(uuid.uuid4())
JOBS[task_id] = {
"id": task_id,
"query": text,
"status": "pending",
"priority_used": None,
"files": [],
"error": None
}
# Pass chat_id to the background task (we will update run_download_task to support this)
background_tasks.add_task(run_download_task, task_id, text, PROXY_URL, JOBS, chat_id)
send_telegram_message(chat_id, f"📥 Queued: '{text}'\nTask ID: {task_id}")
except Exception as e:
logger.error(f"Error handling Telegram webhook: {e}")
return {"status": "processed"}
@app.get("/", response_class=HTMLResponse)
def index_page():
"""
Serves a highly-styled, responsive dark-mode portal.
"""
html_content = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MusicSeerr - Music Ingestion Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-main: #0B0F19;
--bg-card: #151D30;
--bg-input: #1F2A45;
--accent-primary: #8B5CF6;
--accent-secondary: #EC4899;
--text-main: #F3F4F6;
--text-muted: #9CA3AF;
--success: #10B981;
--warning: #F59E0B;
--error: #EF4444;
--info: #3B82F6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Outfit', sans-serif;
}
body {
background: linear-gradient(135deg, var(--bg-main) 0%, #080B13 100%);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
header {
text-align: center;
margin-bottom: 2.5rem;
max-width: 600px;
}
header h1 {
font-size: 3rem;
font-weight: 800;
background: linear-gradient(to right, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
header p {
color: var(--text-muted);
font-size: 1.1rem;
}
.container {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
gap: 2rem;
}
.card {
background: var(--bg-card);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
form {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-weight: 600;
color: var(--text-main);
}
input[type="text"] {
width: 100%;
padding: 1rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background-color: var(--bg-input);
color: var(--text-main);
font-size: 1rem;
transition: all 0.3s ease;
}
input[type="text"]:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
button {
padding: 1rem;
border: none;
border-radius: 10px;
background: linear-gradient(to right, var(--accent-primary), var(--accent-secondary));
color: white;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 14px 0 rgba(139, 92, 246, 0.4);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(139, 92, 246, 0.6);
}
button:active {
transform: translateY(0);
}
.jobs-section h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
border-left: 4px solid var(--accent-primary);
padding-left: 0.75rem;
}
.job-list {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 500px;
overflow-y: auto;
}
.job-item {
background: var(--bg-input);
border-radius: 10px;
padding: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border-left: 4px solid var(--text-muted);
transition: all 0.3s ease;
}
.job-item.pending { border-left-color: var(--info); }
.job-item.downloading { border-left-color: var(--warning); }
.job-item.completed { border-left-color: var(--success); }
.job-item.failed { border-left-color: var(--error); }
.job-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow: hidden;
}
.job-query {
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-details {
font-size: 0.8rem;
color: var(--text-muted);
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.badge.pending { background: rgba(59, 130, 246, 0.2); color: var(--info); }
.badge.downloading { background: rgba(245, 158, 11, 0.2); color: var(--warning); }
.badge.completed { background: rgba(16, 185, 129, 0.2); color: var(--success); }
.badge.failed { background: rgba(239, 68, 68, 0.2); color: var(--error); }
.status-container {
display: flex;
gap: 2rem;
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
justify-content: center;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--success);
}
</style>
</head>
<body>
<header>
<h1>MusicSeerr 🎵</h1>
<p>Sovereign Music Ingestion Portal for Navidrome</p>
</header>
<div class="container">
<div class="card">
<form id="ingestForm">
<div class="form-group">
<label for="query">Search Query or Media URL</label>
<input type="text" id="query" name="query" placeholder="Song Name, Spotify Link, YouTube Link, or Tubidy Link..." required>
</div>
<button type="submit">Ingest Audio</button>
</form>
</div>
<div class="card jobs-section">
<h2>Active Ingestion Jobs</h2>
<div class="job-list" id="jobList">
<p style="color: var(--text-muted); text-align: center; padding: 2rem;">No active downloads. Submit a link above!</p>
</div>
</div>
</div>
<div class="status-container">
<div class="status-item">
<div class="status-dot"></div>
<span>FastAPI Backend Active</span>
</div>
<div class="status-item">
<div class="status-dot"></div>
<span>VPN Proxy Bound</span>
</div>
</div>
<script>
const form = document.getElementById('ingestForm');
const queryInput = document.getElementById('query');
const jobList = document.getElementById('jobList');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const query = queryInput.value.trim();
if (!query) return;
try {
const response = await fetch('/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
});
if (response.ok) {
queryInput.value = '';
loadJobs();
} else {
alert('Failed to submit ingestion job.');
}
} catch (err) {
console.error('Error submitting job:', err);
}
});
async function loadJobs() {
try {
const response = await fetch('/jobs');
if (response.ok) {
const jobs = await response.json();
if (jobs.length === 0) {
jobList.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 2rem;">No active downloads. Submit a link above!</p>';
return;
}
// Sort by completion / timestamp (newest first)
jobs.reverse();
jobList.innerHTML = jobs.map(job => {
let details = '';
if (job.status === 'completed' && job.files.length > 0) {
details = `Imported: ${job.files.join(', ')}`;
} else if (job.status === 'failed') {
details = `Error: ${job.error || 'Unknown error occurred'}`;
} else if (job.status === 'downloading') {
details = `Downloading via ${job.priority_used || 'pipeline'}...`;
} else {
details = 'Queued in background tasks';
}
return `
<div class="job-item ${job.status}">
<div class="job-info">
<div class="job-query">${escapeHtml(job.query)}</div>
<div class="job-details">${escapeHtml(details)}</div>
</div>
<span class="badge ${job.status}">${job.status}</span>
</div>
`;
}).join('');
}
} catch (err) {
console.error('Error loading jobs:', err);
}
}
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
// Auto-refresh jobs every 5 seconds
loadJobs();
setInterval(loadJobs, 5000);
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)