1036 lines
43 KiB
Python
1036 lines
43 KiB
Python
"""
|
|
Audio service for fetching and transcoding music.
|
|
Fetches FLAC from Tidal/Deezer and transcodes to MP3 using FFmpeg.
|
|
Uses multiple API endpoints with fallback for reliability.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import asyncio
|
|
import httpx
|
|
import base64
|
|
from typing import Optional, Dict, Any, List, Union
|
|
import logging
|
|
import json
|
|
import tempfile
|
|
from mutagen.flac import FLAC, Picture
|
|
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TRCK, TDRC, TCON, APIC, COMM
|
|
from mutagen.mp3 import MP3, EasyMP3
|
|
from mutagen.mp4 import MP4, MP4Cover
|
|
|
|
import re
|
|
from app.cache import is_cached, get_cached_file, cache_file, get_cache_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Configuration
|
|
BITRATE = os.environ.get("MP3_BITRATE", "320k")
|
|
DEEZER_API_URL = os.environ.get("DEEZER_API_URL", "https://api.deezmate.com")
|
|
|
|
# FFmpeg path - check common locations on Windows
|
|
FFMPEG_PATH = os.environ.get("FFMPEG_PATH", "ffmpeg")
|
|
if os.name == 'nt' and FFMPEG_PATH == "ffmpeg":
|
|
# Try common Windows locations
|
|
winget_path = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages")
|
|
if os.path.exists(winget_path):
|
|
for root, dirs, files in os.walk(winget_path):
|
|
if "ffmpeg.exe" in files:
|
|
FFMPEG_PATH = os.path.join(root, "ffmpeg.exe")
|
|
break
|
|
|
|
# List of Tidal API endpoints with fallback (fastest/most reliable first)
|
|
TIDAL_APIS = [
|
|
"https://triton.squid.wtf", # From Monochrome - fast primary
|
|
"https://hifi.401658.xyz",
|
|
"https://tidal.kinoplus.online",
|
|
"https://tidal-api.binimum.org",
|
|
"https://wolf.qqdl.site",
|
|
"https://maus.qqdl.site",
|
|
"https://vogel.qqdl.site",
|
|
"https://katze.qqdl.site",
|
|
"https://hund.qqdl.site",
|
|
]
|
|
|
|
|
|
|
|
class AudioService:
|
|
"""Service for fetching and transcoding audio."""
|
|
|
|
# Tidal credentials (same as SpotiFLAC)
|
|
TIDAL_CLIENT_ID = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode()
|
|
TIDAL_CLIENT_SECRET = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode()
|
|
|
|
async def import_url(self, url: str) -> Optional[Dict[str, Any]]:
|
|
"""Import track or playlist from URL using yt-dlp."""
|
|
try:
|
|
# Phish.in Custom Handler (Fast Path)
|
|
if "phish.in" in url:
|
|
logger.info("Detected Phish.in URL, using custom API handler")
|
|
phish_data = await self._import_phish_in(url)
|
|
if phish_data: return phish_data
|
|
|
|
loop = asyncio.get_event_loop()
|
|
info = await loop.run_in_executor(None, lambda: self._extract_info_safe(url))
|
|
|
|
if not info:
|
|
return None
|
|
|
|
# Check if it's a playlist/album
|
|
if 'entries' in info and info['entries']:
|
|
logger.info(f"Detected playlist: {info.get('title')}")
|
|
tracks = []
|
|
for entry in info['entries']:
|
|
if not entry: continue
|
|
|
|
# Determine playback URL (webpage_url for recalculation, or direct url)
|
|
# For stability, we prefer the webpage_url if it's a separate page,
|
|
# OR we use the original URL with an index?
|
|
# Ideally, entry has 'webpage_url' or 'url'.
|
|
# For yt-dlp, 'url' might be the stream url (which expires). 'webpage_url' is persistent.
|
|
play_url = entry.get('webpage_url') or entry.get('url')
|
|
if not play_url: continue
|
|
|
|
safe_t_id = f"LINK:{base64.urlsafe_b64encode(play_url.encode()).decode()}"
|
|
duration_s = entry.get('duration', 0)
|
|
|
|
tracks.append({
|
|
'id': safe_t_id,
|
|
'name': entry.get('title', 'Unknown Title'),
|
|
'artists': entry.get('uploader', entry.get('artist', 'Unknown Artist')),
|
|
'album_art': entry.get('thumbnail', info.get('thumbnail', '/static/icon.svg')),
|
|
'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}",
|
|
'album': info.get('title', 'Imported Playlist'),
|
|
'isrc': safe_t_id # Use ID as ISRC for internal logic
|
|
})
|
|
|
|
if not tracks: return None
|
|
|
|
return {
|
|
'type': 'album',
|
|
'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}",
|
|
'name': info.get('title', 'Imported Playlist'),
|
|
'artists': info.get('uploader', 'Various'),
|
|
'image': info.get('thumbnail', '/static/icon.svg'), # Use album art
|
|
'release_date': info.get('upload_date', ''),
|
|
'tracks': tracks,
|
|
'total_tracks': len(tracks),
|
|
'is_custom': True
|
|
}
|
|
|
|
# Single Track Logic
|
|
safe_id = f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}"
|
|
duration_s = info.get('duration', 0)
|
|
|
|
track = {
|
|
'id': safe_id,
|
|
'name': info.get('title', 'Unknown Title'),
|
|
'artists': info.get('uploader', info.get('artist', 'Unknown Artist')),
|
|
'album_art': info.get('thumbnail', '/static/icon.svg'),
|
|
'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}",
|
|
'album': info.get('extractor_key', 'Imported'),
|
|
'isrc': safe_id
|
|
}
|
|
return track
|
|
except Exception as e:
|
|
logger.error(f"Import error: {e}")
|
|
return None
|
|
|
|
def _extract_info_safe(self, url):
|
|
try:
|
|
import yt_dlp
|
|
ydl_opts = {
|
|
'quiet': True,
|
|
'no_warnings': True,
|
|
'format': 'bestaudio/best',
|
|
}
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
return ydl.extract_info(url, download=False)
|
|
except Exception as e:
|
|
logger.error(f"yt-dlp error: {e}")
|
|
return None
|
|
|
|
async def _import_phish_in(self, url: str) -> Optional[Dict[str, Any]]:
|
|
"""Import show from Phish.in API."""
|
|
try:
|
|
# Extract date YYYY-MM-DD
|
|
match = re.search(r'(\d{4}-\d{2}-\d{2})', url)
|
|
if not match:
|
|
logger.warning("Could not extract date from Phish.in URL")
|
|
return None
|
|
|
|
date = match.group(1)
|
|
api_url = f"https://phish.in/api/v2/shows/{date}"
|
|
|
|
logger.info(f"Fetching Phish.in API: {api_url}")
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(api_url, timeout=15.0)
|
|
if response.status_code != 200:
|
|
return None
|
|
|
|
data = response.json()
|
|
|
|
tracks_list = []
|
|
show_meta = {}
|
|
|
|
# Handle v2 (List of tracks? or Object with tracks?)
|
|
# Swagger says implementation differs. Based on curl, likely a List.
|
|
if isinstance(data, list):
|
|
tracks_list = data
|
|
if tracks_list:
|
|
show_meta = tracks_list[0]
|
|
elif isinstance(data, dict):
|
|
if 'data' in data: data = data['data']
|
|
if 'tracks' in data:
|
|
tracks_list = data['tracks']
|
|
show_meta = data
|
|
else:
|
|
# Maybe data IS the track list?
|
|
pass
|
|
|
|
if not tracks_list: return None
|
|
|
|
tracks = []
|
|
# extracting metadata
|
|
venue = show_meta.get('venue_name', show_meta.get('venue', {}).get('name', 'Unknown Venue'))
|
|
show_date = show_meta.get('show_date', show_meta.get('date', date))
|
|
|
|
album_name = f"{show_date} - {venue}"
|
|
|
|
for t in tracks_list:
|
|
# mp3 url is usually http, ensure https if possible or leave as is
|
|
mp3_url = t.get('mp3_url') or t.get('mp3')
|
|
if not mp3_url: continue
|
|
|
|
safe_id = f"LINK:{base64.urlsafe_b64encode(mp3_url.encode()).decode()}"
|
|
duration_s = t.get('duration', 0) / 1000.0 if t.get('duration', 0) > 10000 else t.get('duration', 0)
|
|
# v2 duration seems to be ms? curl say 666600 (666s = 11m). So ms.
|
|
|
|
tracks.append({
|
|
'id': safe_id,
|
|
'name': t.get('title', 'Unknown'),
|
|
'artists': 'Phish',
|
|
'album': album_name,
|
|
'album_art': t.get('show_album_cover_url', '/static/icon.svg'),
|
|
'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}",
|
|
'isrc': safe_id
|
|
})
|
|
|
|
if not tracks: return None
|
|
|
|
return {
|
|
'type': 'album',
|
|
'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}",
|
|
'name': album_name,
|
|
'artists': 'Phish',
|
|
'image': tracks[0]['album_art'],
|
|
'release_date': show_date,
|
|
'tracks': tracks,
|
|
'total_tracks': len(tracks),
|
|
'is_custom': True
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Phish.in import error: {e}")
|
|
return None
|
|
|
|
def _get_stream_url(self, url: str) -> Optional[str]:
|
|
"""Get the actual stream URL from a page URL using yt-dlp.
|
|
For direct audio files (.mp3, .m4a, etc.), return as-is.
|
|
"""
|
|
# Check if URL is already a direct audio file
|
|
from urllib.parse import urlparse
|
|
parsed = urlparse(url)
|
|
path_lower = parsed.path.lower()
|
|
audio_extensions = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.flac', '.opus')
|
|
if any(path_lower.endswith(ext) for ext in audio_extensions):
|
|
logger.info(f"Direct audio URL detected, bypassing yt-dlp: {url[:60]}...")
|
|
return url
|
|
|
|
|
|
# Check cache
|
|
import time
|
|
now = time.time()
|
|
if url in self._stream_url_cache:
|
|
cached_url, expiry = self._stream_url_cache[url]
|
|
if now < expiry:
|
|
logger.info("Stream URL cache hit")
|
|
return cached_url
|
|
else:
|
|
del self._stream_url_cache[url]
|
|
|
|
# Use yt-dlp for page URLs (YouTube, Bandcamp, etc.)
|
|
info = self._extract_info_safe(url)
|
|
if not info: return None
|
|
if 'entries' in info: info = info['entries'][0]
|
|
|
|
stream_url = info.get('url')
|
|
if stream_url:
|
|
# Cache for 1 hour (Google URLs usually expire in ~4-6 hours)
|
|
self._stream_url_cache[url] = (stream_url, now + 3600)
|
|
|
|
return stream_url
|
|
|
|
|
|
|
|
|
|
|
|
# Simple in-memory cache for resolved stream URLs (to speed up seeking)
|
|
_stream_url_cache = {} # {url: (stream_url, expire_time)}
|
|
|
|
def __init__(self):
|
|
# Enable redirect following and increase timeout
|
|
# Using a shared client with a connection pool to avoid socket exhaustion
|
|
limits = httpx.Limits(max_keepalive_connections=50, max_connections=100)
|
|
self.client = httpx.AsyncClient(timeout=60.0, follow_redirects=True, limits=limits)
|
|
|
|
self.tidal_token: Optional[str] = None
|
|
self.working_api: Optional[str] = None # Cache the last working API
|
|
|
|
async def get_tidal_token(self) -> str:
|
|
"""Get Tidal access token."""
|
|
if self.tidal_token:
|
|
return self.tidal_token
|
|
|
|
response = await self.client.post(
|
|
"https://auth.tidal.com/v1/oauth2/token",
|
|
data={
|
|
"client_id": self.TIDAL_CLIENT_ID,
|
|
"grant_type": "client_credentials"
|
|
},
|
|
auth=(self.TIDAL_CLIENT_ID, self.TIDAL_CLIENT_SECRET)
|
|
)
|
|
response.raise_for_status()
|
|
self.tidal_token = response.json()["access_token"]
|
|
return self.tidal_token
|
|
|
|
async def search_tidal_by_isrc(self, isrc: str, query: str = "") -> Optional[Dict[str, Any]]:
|
|
"""Search Tidal for a track by ISRC."""
|
|
try:
|
|
token = await self.get_tidal_token()
|
|
search_query = query or isrc
|
|
|
|
response = await self.client.get(
|
|
"https://api.tidal.com/v1/search/tracks",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
params={
|
|
"query": search_query,
|
|
"limit": 25,
|
|
"offset": 0,
|
|
"countryCode": "US"
|
|
}
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
items = data.get("items", [])
|
|
|
|
# Find by ISRC match
|
|
for item in items:
|
|
if item.get("isrc") == isrc:
|
|
return item
|
|
|
|
# Fall back to first result
|
|
return items[0] if items else None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Tidal search error: {e}")
|
|
return None
|
|
|
|
async def get_tidal_download_url_from_api(self, api_url: str, track_id: int, quality: str = "LOSSLESS") -> Optional[str]:
|
|
"""Get download URL from a specific Tidal API."""
|
|
import base64
|
|
import json as json_module
|
|
|
|
try:
|
|
full_url = f"{api_url}/track/?id={track_id}&quality={quality}"
|
|
logger.info(f"Trying API: {api_url}")
|
|
|
|
response = await self.client.get(full_url, timeout=30.0)
|
|
|
|
if response.status_code != 200:
|
|
logger.warning(f"API {api_url} returned {response.status_code}")
|
|
return None
|
|
|
|
# Check if we got HTML instead of JSON
|
|
content_type = response.headers.get("content-type", "")
|
|
if "html" in content_type.lower():
|
|
logger.warning(f"API {api_url} returned HTML instead of JSON")
|
|
return None
|
|
|
|
try:
|
|
data = response.json()
|
|
except Exception:
|
|
logger.warning(f"API {api_url} returned invalid JSON")
|
|
return None
|
|
|
|
# Handle API v2.0 format with manifest
|
|
if isinstance(data, dict) and "version" in data and "data" in data:
|
|
inner_data = data.get("data", {})
|
|
manifest_b64 = inner_data.get("manifest")
|
|
|
|
if manifest_b64:
|
|
try:
|
|
manifest_json = base64.b64decode(manifest_b64).decode('utf-8')
|
|
manifest = json_module.loads(manifest_json)
|
|
urls = manifest.get("urls", [])
|
|
|
|
if urls:
|
|
download_url = urls[0]
|
|
logger.info(f"Got download URL from {api_url} (v2.0 manifest)")
|
|
self.working_api = api_url
|
|
return download_url
|
|
except Exception as e:
|
|
logger.warning(f"Failed to decode manifest from {api_url}: {e}")
|
|
|
|
# Handle legacy format (list with OriginalTrackUrl)
|
|
if isinstance(data, list):
|
|
for item in data:
|
|
if isinstance(item, dict) and "OriginalTrackUrl" in item:
|
|
logger.info(f"Got download URL from {api_url} (legacy format)")
|
|
self.working_api = api_url
|
|
return item["OriginalTrackUrl"]
|
|
|
|
# Handle other dict formats
|
|
elif isinstance(data, dict):
|
|
if "OriginalTrackUrl" in data:
|
|
self.working_api = api_url
|
|
return data["OriginalTrackUrl"]
|
|
if "url" in data:
|
|
self.working_api = api_url
|
|
return data["url"]
|
|
|
|
logger.warning(f"API {api_url} returned unexpected format")
|
|
return None
|
|
|
|
except httpx.TimeoutException:
|
|
logger.warning(f"API {api_url} timed out")
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"API {api_url} error: {e}")
|
|
return None
|
|
|
|
async def update_tidal_apis(self):
|
|
"""Update available Tidal APIs from status server."""
|
|
try:
|
|
# Only update once per session to avoid delay
|
|
if hasattr(self, '_apis_updated') and self._apis_updated:
|
|
return
|
|
|
|
logger.info("Updating Tidal API list...")
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
async with client.stream("GET", "https://status.monochrome.tf/api/stream") as response:
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("data: "):
|
|
data = json.loads(line[6:])
|
|
|
|
api_instances = [
|
|
inst for inst in data.get('instances', [])
|
|
if inst.get('instance_type') == 'api' and inst.get('last_check', {}).get('success')
|
|
]
|
|
|
|
# Sort by avg_response_time
|
|
api_instances.sort(key=lambda x: x.get('avg_response_time', 9999))
|
|
|
|
new_apis = [api['url'] for api in api_instances if api.get('url')]
|
|
|
|
if new_apis:
|
|
global TIDAL_APIS
|
|
TIDAL_APIS = new_apis
|
|
self._apis_updated = True
|
|
logger.info(f"Updated Tidal API list with {len(new_apis)} servers")
|
|
break # Found data, done
|
|
except Exception as e:
|
|
logger.warning(f"Failed to update Tidal APIs: {e}")
|
|
|
|
def embed_metadata(self, audio_data: bytes, format: str, metadata: Dict) -> bytes:
|
|
"""Embed metadata into audio file (MP3/FLAC)."""
|
|
if not metadata: return audio_data
|
|
|
|
logger.info(f"Embedding metadata for {format}: {metadata.get('title')} - {metadata.get('year')}")
|
|
|
|
try:
|
|
suffix = ".flac" if format == "flac" else ".mp3"
|
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
|
tmp.write(audio_data)
|
|
tmp_path = tmp.name
|
|
|
|
if format == "flac":
|
|
audio = FLAC(tmp_path)
|
|
audio.clear_pictures()
|
|
|
|
if metadata.get("title"): audio["TITLE"] = metadata["title"]
|
|
if metadata.get("artists"): audio["ARTIST"] = metadata["artists"]
|
|
if metadata.get("album"): audio["ALBUM"] = metadata["album"]
|
|
if metadata.get("year"): audio["DATE"] = str(metadata["year"])[:4]
|
|
if metadata.get("track_number"): audio["TRACKNUMBER"] = str(metadata["track_number"])
|
|
|
|
if metadata.get("album_art_data"):
|
|
picture = Picture()
|
|
picture.data = metadata["album_art_data"]
|
|
picture.type = PictureType.COVER_FRONT
|
|
picture.mime = "image/jpeg"
|
|
audio.add_picture(picture)
|
|
audio.save()
|
|
|
|
elif format in ["mp3", "mp3_128"]:
|
|
try:
|
|
audio = MP3(tmp_path, ID3=ID3)
|
|
except:
|
|
audio = MP3(tmp_path)
|
|
audio.add_tags()
|
|
|
|
# ID3 Tags
|
|
if metadata.get("title"): audio.tags.add(TIT2(encoding=3, text=metadata["title"]))
|
|
if metadata.get("artists"): audio.tags.add(TPE1(encoding=3, text=metadata["artists"]))
|
|
if metadata.get("album"): audio.tags.add(TALB(encoding=3, text=metadata["album"]))
|
|
if metadata.get("year"): audio.tags.add(TDRC(encoding=3, text=str(metadata["year"])[:4]))
|
|
if metadata.get("track_number"): audio.tags.add(TRCK(encoding=3, text=str(metadata["track_number"])))
|
|
|
|
if metadata.get("album_art_data"):
|
|
audio.tags.add(
|
|
APIC(
|
|
encoding=3,
|
|
mime='image/jpeg',
|
|
type=3,
|
|
desc='Cover',
|
|
data=metadata["album_art_data"]
|
|
)
|
|
)
|
|
audio.save()
|
|
|
|
with open(tmp_path, 'rb') as f:
|
|
tagged_data = f.read()
|
|
|
|
os.remove(tmp_path)
|
|
return tagged_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Metadata tagging error: {e}")
|
|
if os.path.exists(tmp_path): os.remove(tmp_path)
|
|
return audio_data
|
|
|
|
async def get_tidal_download_url(self, track_id: int, quality: str = "LOSSLESS") -> Optional[str]:
|
|
"""Get download URL from Tidal APIs with fallback."""
|
|
|
|
# Update APIs first
|
|
await self.update_tidal_apis()
|
|
|
|
# Build API list with the last working API first
|
|
apis_to_try = list(TIDAL_APIS)
|
|
if self.working_api and self.working_api in apis_to_try:
|
|
apis_to_try.remove(self.working_api)
|
|
apis_to_try.insert(0, self.working_api)
|
|
|
|
# Try each API until one works
|
|
for api_url in apis_to_try:
|
|
download_url = await self.get_tidal_download_url_from_api(api_url, track_id, quality)
|
|
if download_url:
|
|
return download_url
|
|
|
|
logger.error("All Tidal APIs failed")
|
|
return None
|
|
|
|
async def _fetch_tidal_cover(self, cover_uuid: str) -> Optional[bytes]:
|
|
"""Fetch Tidal album art."""
|
|
try:
|
|
url = f"https://resources.tidal.com/images/{cover_uuid.replace('-', '/')}/1280x1280.jpg"
|
|
response = await self.client.get(url)
|
|
if response.status_code == 200:
|
|
return response.content
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
async def get_deezer_track_info(self, isrc: str) -> Optional[Dict]:
|
|
"""Get Deezer track info from ISRC."""
|
|
try:
|
|
response = await self.client.get(
|
|
f"https://api.deezer.com/2.0/track/isrc:{isrc}"
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
if "error" not in data:
|
|
return data
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Deezer lookup error: {e}")
|
|
return None
|
|
|
|
async def get_deezer_download_url(self, track_id: int) -> Optional[str]:
|
|
"""Get FLAC download URL from Deezer API."""
|
|
try:
|
|
response = await self.client.get(
|
|
f"{DEEZER_API_URL}/dl/{track_id}",
|
|
timeout=30.0
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.warning(f"Deezer API returned {response.status_code}")
|
|
return None
|
|
|
|
data = response.json()
|
|
if data.get("success"):
|
|
return data.get("links", {}).get("flac")
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Deezer download URL error: {e}")
|
|
return None
|
|
|
|
async def fetch_tidal_metadata(self, track: Dict) -> Dict:
|
|
"""Extract metadata from Tidal track object."""
|
|
try:
|
|
album = track.get("album", {})
|
|
artist = track.get("artist", {})
|
|
if not artist and track.get("artists"):
|
|
artist = track.get("artists")[0]
|
|
|
|
cover_uuid = album.get("cover")
|
|
album_art_data = None
|
|
if cover_uuid:
|
|
album_art_data = await self._fetch_tidal_cover(cover_uuid)
|
|
|
|
return {
|
|
"title": track.get("title"),
|
|
"artist": artist.get("name"),
|
|
"artists": artist.get("name"), # For embed_metadata
|
|
"album": album.get("title"),
|
|
"year": track.get("releaseDate", "")[:4],
|
|
"track_number": track.get("trackNumber"),
|
|
"album_art_data": album_art_data,
|
|
"album_art_url": None
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Metadata extraction error: {e}")
|
|
return {}
|
|
|
|
async def fetch_flac(self, isrc: str, query: str = "", hires: bool = True) -> Optional[Union[tuple[bytes, Dict], tuple[str, Dict]]]:
|
|
"""Fetch FLAC audio and metadata from Tidal or Deezer (with fallback)."""
|
|
|
|
deezer_info = None # Cache for potential metadata use
|
|
|
|
# Handle Deezer track IDs (dz_XXXXX format) - extract ISRC first
|
|
if isrc.startswith("dz_"):
|
|
deezer_track_id = isrc.replace("dz_", "")
|
|
logger.info(f"Deezer track ID detected: {deezer_track_id}")
|
|
|
|
try:
|
|
# Fetch track info from Deezer public API to get ISRC
|
|
response = await self.client.get(
|
|
f"https://api.deezer.com/track/{deezer_track_id}"
|
|
)
|
|
if response.status_code == 200:
|
|
deezer_info = response.json()
|
|
if "error" not in deezer_info:
|
|
extracted_isrc = deezer_info.get("isrc")
|
|
if extracted_isrc:
|
|
logger.info(f"Extracted ISRC from Deezer: {extracted_isrc}")
|
|
isrc = extracted_isrc # Use real ISRC for Tidal lookup
|
|
query = query or f"{deezer_info.get('title', '')} {deezer_info.get('artist', {}).get('name', '')}"
|
|
else:
|
|
logger.warning("No ISRC in Deezer track - will try Deezer download directly")
|
|
except Exception as e:
|
|
logger.error(f"Deezer track info fetch error: {e}")
|
|
|
|
# Handle query: prefixed IDs (from ListenBrainz playlists)
|
|
# These are searchable by artist + title, no ISRC available
|
|
if isrc.startswith("query:"):
|
|
query = isrc.replace("query:", "")
|
|
isrc = "" # Clear ISRC, will search by query only
|
|
logger.info(f"ListenBrainz track - searching by query: {query}")
|
|
|
|
|
|
# 0. Try Dab Music (Qobuz Hi-Res Proxy) - New! Priority #1
|
|
try:
|
|
from app.dab_service import dab_service
|
|
|
|
dab_id = None
|
|
dab_track = None # Initialize to avoid UnboundLocalError
|
|
|
|
if isrc.startswith("dab_"):
|
|
dab_id = isrc
|
|
else:
|
|
# Search prioritization:
|
|
# Prefer standard query (Artist + Title) because ISRC support on Dab is flaky.
|
|
dab_query = query
|
|
if not dab_query and isrc and not isrc.startswith("dz_"):
|
|
dab_query = f"isrc:{isrc}"
|
|
|
|
if dab_query:
|
|
dab_tracks = await dab_service.search_tracks(dab_query, limit=1)
|
|
if dab_tracks:
|
|
dab_track = dab_tracks[0]
|
|
dab_id = dab_track.get('id')
|
|
|
|
# Optional: Verify title match if using query?
|
|
# For now assume top result is correct as Dab/Qobuz search is usually okay for metadata.
|
|
|
|
if dab_id:
|
|
# Select quality
|
|
quality = "27" if hires else "7"
|
|
stream_url = await dab_service.get_stream_url(dab_id, quality=quality)
|
|
|
|
if stream_url:
|
|
logger.info(f"Dab Stream URL found: {stream_url[:40]}...")
|
|
|
|
# Metadata gathering
|
|
# Use Dab metadata if available, otherwise fallback to query string
|
|
if dab_track:
|
|
metadata = {
|
|
"title": dab_track.get("title"),
|
|
"artist": dab_track.get("artist"),
|
|
"album": dab_track.get("albumTitle"),
|
|
"year": dab_track.get("releaseDate", "")[:4] if dab_track.get("releaseDate") else "",
|
|
"album_art_url": dab_track.get("albumCover"),
|
|
"album_art_data": None
|
|
}
|
|
# Ensure artist/album are strings
|
|
if isinstance(metadata["artist"], dict): metadata["artist"] = metadata["artist"].get("name")
|
|
if isinstance(metadata["album"], dict): metadata["album"] = metadata["album"].get("title")
|
|
else:
|
|
# Fallback: extract from query string (format: "Title Artist")
|
|
parts = query.split(" ") if query else []
|
|
metadata = {
|
|
"title": query or "Unknown",
|
|
"artist": "",
|
|
"album": "",
|
|
"year": "",
|
|
"album_art_url": None,
|
|
"album_art_data": None
|
|
}
|
|
|
|
metadata["is_hi_res"] = True
|
|
return (stream_url, metadata)
|
|
except Exception as e:
|
|
logger.error(f"Dab Music fetch error: {e}")
|
|
|
|
# 1. Try Tidal (Primary Source)
|
|
if not isrc.startswith("dz_"): # Only if we have a real ISRC
|
|
logger.info(f"Trying Tidal APIs for ISRC: {isrc}")
|
|
tidal_track = await self.search_tidal_by_isrc(isrc, query)
|
|
|
|
if tidal_track:
|
|
track_id = tidal_track.get("id")
|
|
download_url = await self.get_tidal_download_url(track_id)
|
|
|
|
if download_url:
|
|
logger.info(f"Downloading from Tidal: {download_url[:80]}...")
|
|
try:
|
|
response = await self.client.get(download_url, timeout=180.0)
|
|
if response.status_code == 200:
|
|
size_mb = len(response.content) / 1024 / 1024
|
|
logger.info(f"Downloaded {size_mb:.2f} MB from Tidal")
|
|
|
|
meta = {
|
|
"title": tidal_track.get("title"),
|
|
"artists": [a["name"] for a in tidal_track.get("artists", [])],
|
|
"album": tidal_track.get("album", {}).get("title"),
|
|
"year": tidal_track.get("album", {}).get("releaseDate"),
|
|
"track_number": tidal_track.get("trackNumber"),
|
|
}
|
|
cover_uuid = tidal_track.get("album", {}).get("cover")
|
|
if cover_uuid:
|
|
meta["album_art_data"] = await self._fetch_tidal_cover(cover_uuid)
|
|
|
|
return (response.content, meta)
|
|
except Exception as e:
|
|
logger.error(f"Tidal download error: {e}")
|
|
else:
|
|
logger.warning(f"Tidal search returned no results for: {isrc}")
|
|
|
|
# Fallback to Deezer FLAC download (deezmate API)
|
|
logger.info(f"Falling back to Deezer for: {isrc or query}")
|
|
|
|
# If we have cached deezer_info from above, use it; otherwise fetch
|
|
if not deezer_info and isrc.startswith("dz_"):
|
|
deezer_track_id = isrc.replace("dz_", "")
|
|
try:
|
|
response = await self.client.get(f"https://api.deezer.com/track/{deezer_track_id}")
|
|
if response.status_code == 200:
|
|
deezer_info = response.json()
|
|
except:
|
|
pass
|
|
elif not deezer_info and isrc:
|
|
# Lookup by ISRC
|
|
deezer_info = await self.get_deezer_track_info(isrc)
|
|
elif not deezer_info and query:
|
|
# No ISRC - search by query (for ListenBrainz tracks)
|
|
try:
|
|
response = await self.client.get(
|
|
"https://api.deezer.com/search/track",
|
|
params={"q": query, "limit": 1}
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
tracks = data.get("data", [])
|
|
if tracks:
|
|
deezer_info = tracks[0]
|
|
logger.info(f"Deezer search found: {deezer_info.get('title')} by {deezer_info.get('artist', {}).get('name')}")
|
|
except Exception as e:
|
|
logger.error(f"Deezer search error: {e}")
|
|
|
|
if deezer_info and "error" not in deezer_info:
|
|
deezer_id = deezer_info.get("id")
|
|
download_url = await self.get_deezer_download_url(deezer_id)
|
|
|
|
if download_url:
|
|
logger.info(f"Downloading from Deezer (deezmate)...")
|
|
try:
|
|
response = await self.client.get(download_url, timeout=180.0)
|
|
if response.status_code == 200:
|
|
logger.info(f"Downloaded {len(response.content) / 1024 / 1024:.2f} MB from Deezer")
|
|
|
|
meta = {
|
|
"title": deezer_info.get("title"),
|
|
"artists": [a["name"] for a in deezer_info.get("contributors", [])] or [deezer_info.get("artist", {}).get("name")],
|
|
"album": deezer_info.get("album", {}).get("title"),
|
|
"year": deezer_info.get("release_date"),
|
|
"track_number": deezer_info.get("track_position"),
|
|
}
|
|
cover_url = deezer_info.get("album", {}).get("cover_xl")
|
|
if cover_url:
|
|
try:
|
|
cover_resp = await self.client.get(cover_url)
|
|
if cover_resp.status_code == 200:
|
|
meta["album_art_data"] = cover_resp.content
|
|
except: pass
|
|
|
|
return (response.content, meta)
|
|
except Exception as e:
|
|
logger.error(f"Deezer download error: {e}")
|
|
|
|
logger.error(f"Could not fetch audio for: {isrc}")
|
|
return None
|
|
|
|
def transcode_to_mp3(self, flac_data: bytes, bitrate: str = BITRATE) -> Optional[bytes]:
|
|
"""Transcode FLAC to MP3 using FFmpeg."""
|
|
try:
|
|
# Use FFmpeg with stdin/stdout for streaming
|
|
process = subprocess.Popen(
|
|
[
|
|
FFMPEG_PATH,
|
|
"-i", "pipe:0", # Read from stdin
|
|
"-vn", # No video
|
|
"-acodec", "libmp3lame", # MP3 encoder
|
|
"-b:a", bitrate, # Bitrate
|
|
"-f", "mp3", # Output format
|
|
"pipe:1" # Write to stdout
|
|
],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE
|
|
)
|
|
|
|
mp3_data, stderr = process.communicate(input=flac_data)
|
|
|
|
if process.returncode != 0:
|
|
logger.error(f"FFmpeg error: {stderr.decode()[:500]}")
|
|
return None
|
|
|
|
logger.info(f"Transcoded to MP3: {len(mp3_data) / 1024 / 1024:.2f} MB")
|
|
return mp3_data
|
|
|
|
except FileNotFoundError:
|
|
logger.error("FFmpeg not found! Please install FFmpeg.")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Transcode error: {e}")
|
|
return None
|
|
|
|
async def get_audio_stream(self, isrc: str, query: str = "") -> Optional[bytes]:
|
|
"""Get transcoded MP3 audio, using cache if available."""
|
|
|
|
# Check cache first
|
|
if is_cached(isrc, "mp3"):
|
|
logger.info(f"Cache hit for {isrc}")
|
|
cached_data = await get_cached_file(isrc, "mp3")
|
|
if cached_data:
|
|
return cached_data
|
|
|
|
|
|
|
|
# Fetch and transcode
|
|
logger.info(f"Cache miss for {isrc}, fetching...")
|
|
result = await self.fetch_flac(isrc, query)
|
|
|
|
if not result:
|
|
return None
|
|
|
|
flac_data, metadata = result
|
|
|
|
# Transcode (run in executor to not block)
|
|
loop = asyncio.get_event_loop()
|
|
mp3_data = await loop.run_in_executor(None, self.transcode_to_mp3, flac_data)
|
|
|
|
if mp3_data:
|
|
# Cache the result
|
|
await cache_file(isrc, mp3_data, "mp3")
|
|
|
|
return mp3_data
|
|
|
|
|
|
|
|
# Format configurations for FFmpeg
|
|
FORMAT_CONFIG = {
|
|
"mp3": {
|
|
"ext": ".mp3",
|
|
"mime": "audio/mpeg",
|
|
"args": ["-acodec", "libmp3lame", "-b:a", "320k", "-f", "mp3"]
|
|
},
|
|
"mp3_128": {
|
|
"ext": ".mp3",
|
|
"mime": "audio/mpeg",
|
|
"args": ["-acodec", "libmp3lame", "-b:a", "128k", "-f", "mp3"]
|
|
},
|
|
"flac": {
|
|
"ext": ".flac",
|
|
"mime": "audio/flac",
|
|
"args": ["-acodec", "flac", "-sample_fmt", "s16", "-f", "flac"] # Force 16-bit
|
|
},
|
|
"flac_24": {
|
|
"ext": ".flac",
|
|
"mime": "audio/flac",
|
|
"args": ["-acodec", "flac", "-sample_fmt", "s32", "-f", "flac"] # 24-bit preserved
|
|
},
|
|
"aiff": {
|
|
"ext": ".aiff",
|
|
"mime": "audio/aiff",
|
|
"args": ["-acodec", "pcm_s16be", "-f", "aiff"]
|
|
},
|
|
"wav": {
|
|
"ext": ".wav",
|
|
"mime": "audio/wav",
|
|
"args": ["-acodec", "pcm_s16le", "-f", "wav"]
|
|
},
|
|
"wav_24": {
|
|
"ext": ".wav",
|
|
"mime": "audio/wav",
|
|
"args": ["-acodec", "pcm_s24le", "-f", "wav"]
|
|
},
|
|
"alac": {
|
|
"ext": ".m4a",
|
|
"mime": "audio/mp4",
|
|
"args": ["-acodec", "alac", "-f", "ipod"]
|
|
},
|
|
"aiff_24": {
|
|
"ext": ".aiff",
|
|
"mime": "audio/aiff",
|
|
"args": ["-acodec", "pcm_s24be", "-f", "aiff"]
|
|
}
|
|
}
|
|
|
|
def transcode_to_format(self, flac_data: bytes, format: str = "mp3") -> Optional[bytes]:
|
|
"""Transcode FLAC to specified format using FFmpeg."""
|
|
config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"])
|
|
|
|
try:
|
|
logger.info(f"Transcoding to {format} using FFmpeg at: {FFMPEG_PATH}")
|
|
# Note: flac_24 goes through FFmpeg to ensure proper sample format
|
|
|
|
cmd = [
|
|
FFMPEG_PATH,
|
|
"-i", "pipe:0", # Read from stdin
|
|
"-vn", # No video
|
|
] + config["args"] + [
|
|
"pipe:1" # Write to stdout
|
|
]
|
|
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE
|
|
)
|
|
|
|
output_data, stderr = process.communicate(input=flac_data)
|
|
|
|
if process.returncode != 0:
|
|
logger.error(f"FFmpeg error: {stderr.decode()[:500]}")
|
|
return None
|
|
|
|
logger.info(f"Transcoded to {format}: {len(output_data) / 1024 / 1024:.2f} MB")
|
|
return output_data
|
|
|
|
except FileNotFoundError:
|
|
logger.error("FFmpeg not found!")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Transcode error: {e}")
|
|
return None
|
|
|
|
async def get_download_audio(self, isrc: str, query: str, format: str = "mp3") -> Optional[tuple]:
|
|
"""Get audio in specified format for download. Returns (data, extension, mime_type)."""
|
|
|
|
config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"])
|
|
cache_ext = format if format != "mp3_128" else "mp3_128"
|
|
|
|
# Skip cache for downloads to ensure we get fresh metadata
|
|
# if is_cached(isrc, cache_ext):
|
|
# ...
|
|
|
|
# Fetch FLAC
|
|
logger.info(f"Fetching audio for download (skipping cache to ensure tags): {isrc}")
|
|
|
|
# Handle Imported Links
|
|
if isrc.startswith("LINK:"):
|
|
# ... (existing link handling)
|
|
return None # Todo: handle link tagging similarly if possible
|
|
|
|
result = await self.fetch_flac(isrc, query)
|
|
|
|
if not result:
|
|
return None
|
|
|
|
flac_data, metadata = result
|
|
|
|
# Enrich metadata with MusicBrainz (release year, label, better cover art)
|
|
try:
|
|
from app.musicbrainz_service import musicbrainz_service
|
|
mb_data = await musicbrainz_service.lookup_by_isrc(isrc)
|
|
if mb_data:
|
|
# Fill in missing fields from MusicBrainz
|
|
if not metadata.get("year") and mb_data.get("release_date"):
|
|
metadata["year"] = mb_data["release_date"]
|
|
if mb_data.get("label"):
|
|
metadata["label"] = mb_data["label"]
|
|
# Use MusicBrainz cover art if we don't have one
|
|
if not metadata.get("album_art_data") and mb_data.get("cover_art_url"):
|
|
try:
|
|
cover_resp = await self.client.get(mb_data["cover_art_url"])
|
|
if cover_resp.status_code == 200:
|
|
metadata["album_art_data"] = cover_resp.content
|
|
logger.info("Using cover art from Cover Art Archive")
|
|
except: pass
|
|
except Exception as e:
|
|
logger.debug(f"MusicBrainz enrichment skipped: {e}")
|
|
|
|
# Transcode/Passthrough
|
|
loop = asyncio.get_event_loop()
|
|
output_data = await loop.run_in_executor(
|
|
None, self.transcode_to_format, flac_data, format
|
|
)
|
|
|
|
if output_data:
|
|
# Embed Metadata
|
|
output_data = await loop.run_in_executor(
|
|
None, self.embed_metadata, output_data, format, metadata
|
|
)
|
|
|
|
# Cache it? Maybe not since it has user-specific tags?
|
|
# Actually standard tags are fine.
|
|
# await cache_file(isrc, output_data, cache_ext)
|
|
return (output_data, config["ext"], config["mime"])
|
|
|
|
return None
|
|
|
|
async def close(self):
|
|
"""Close the HTTP client."""
|
|
await self.client.aclose()
|
|
# Close Dab Service
|
|
try:
|
|
from app.dab_service import dab_service
|
|
await dab_service.close()
|
|
except: pass
|
|
|
|
|
|
# Singleton instance
|
|
audio_service = AudioService()
|