577 lines
20 KiB
Python
577 lines
20 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
// Auto-refresh jobs every 5 seconds
|
|
loadJobs();
|
|
setInterval(loadJobs, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
def run_lastfm_auto_download(username: str):
|
|
url = f"https://lfm.xiffy.nl/{username}/recommended"
|
|
proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else None
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
}
|
|
|
|
logger.info(f"Fetching Last.fm recommendations feed from {url}")
|
|
try:
|
|
response = requests.get(url, headers=headers, proxies=proxies, timeout=20)
|
|
response.raise_for_status()
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch Last.fm recommendations feed: {e}")
|
|
return
|
|
|
|
try:
|
|
import xml.etree.ElementTree as ET
|
|
root = ET.fromstring(response.content)
|
|
items = root.findall(".//item")
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse Last.fm recommendations XML: {e}")
|
|
return
|
|
|
|
logger.info(f"Found {len(items)} items in Last.fm feed.")
|
|
|
|
# Import tasks dynamically to avoid circular import
|
|
from tasks import run_download_task
|
|
import re
|
|
|
|
def is_track_already_downloaded(track_query: str) -> bool:
|
|
remote_base = "/remote-music/music"
|
|
if not os.path.isdir(remote_base):
|
|
return False
|
|
clean_query = re.sub(r'[^\w\s-]', '', track_query).lower()
|
|
parts = track_query.split(" - ", 1)
|
|
if len(parts) == 2:
|
|
artist, title = parts[0].strip().lower(), parts[1].strip().lower()
|
|
artist_clean = re.sub(r'[^\w\s-]', '', artist)
|
|
title_clean = re.sub(r'[^\w\s-]', '', title)
|
|
else:
|
|
artist_clean = clean_query
|
|
title_clean = clean_query
|
|
|
|
for r, d, files in os.walk(remote_base):
|
|
for file in files:
|
|
if file.lower().endswith((".mp3", ".flac", ".m4a", ".ogg", ".wav", ".opus")):
|
|
file_lower = file.lower()
|
|
if len(parts) == 2:
|
|
if artist_clean in file_lower and title_clean in file_lower:
|
|
return True
|
|
else:
|
|
if clean_query in file_lower:
|
|
return True
|
|
return False
|
|
|
|
for item in items:
|
|
title_el = item.find("title")
|
|
if title_el is not None and title_el.text:
|
|
track_query = title_el.text.strip()
|
|
|
|
# Check if already queued
|
|
already_queued = any(job.get("query") == f"[Last.fm Auto] {track_query}" for job in JOBS.values())
|
|
if already_queued:
|
|
continue
|
|
|
|
if is_track_already_downloaded(track_query):
|
|
logger.info(f"Skipping Last.fm auto-download for '{track_query}' (already exists).")
|
|
continue
|
|
|
|
logger.info(f"Queueing Last.fm recommendation: '{track_query}'")
|
|
task_id = str(uuid.uuid4())
|
|
JOBS[task_id] = {
|
|
"id": task_id,
|
|
"query": f"[Last.fm Auto] {track_query}",
|
|
"status": "pending",
|
|
"priority_used": None,
|
|
"files": [],
|
|
"error": None
|
|
}
|
|
try:
|
|
run_download_task(task_id, track_query, PROXY_URL, JOBS)
|
|
except Exception as e:
|
|
logger.error(f"Failed to process auto-download job {task_id}: {e}")
|
|
|
|
|
|
@app.post("/lastfm/trigger")
|
|
def trigger_lastfm_sync(background_tasks: BackgroundTasks):
|
|
username = os.getenv("LASTFM_AUTO_DOWNLOAD_USER")
|
|
if not username:
|
|
return JSONResponse(status_code=400, content={"error": "Last.fm auto-download is not enabled (LASTFM_AUTO_DOWNLOAD_USER not configured)"})
|
|
background_tasks.add_task(run_lastfm_auto_download, username)
|
|
return {"status": "triggered", "user": username}
|
|
|
|
|
|
import asyncio
|
|
|
|
async def lastfm_scheduler():
|
|
# Wait a short bit after startup before the first check
|
|
await asyncio.sleep(30)
|
|
while True:
|
|
username = os.getenv("LASTFM_AUTO_DOWNLOAD_USER")
|
|
if username:
|
|
try:
|
|
logger.info(f"Triggering scheduled Last.fm recommendations fetch for '{username}'...")
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(None, run_lastfm_auto_download, username)
|
|
except Exception as e:
|
|
logger.error(f"Error in Last.fm scheduler task: {e}")
|
|
|
|
interval_hours = float(os.getenv("LASTFM_AUTO_DOWNLOAD_INTERVAL_HOURS", "12"))
|
|
await asyncio.sleep(interval_hours * 3600)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
asyncio.create_task(lastfm_scheduler())
|