File size: 6,887 Bytes
325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab d842ae1 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab 325ab71 006c7ab |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
import os
import logging
import sys
from datetime import datetime
from typing import List, Optional
import tidalapi
from tidalapi import Track, Quality
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
app = FastAPI()
# --- Logging Setup ---
logger = logging.getLogger("tidalapi_app")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
# --- Authentication ---
def load_tokens_from_secret(session):
secret = os.getenv("TIDAL_OAUTH_TOKENS")
if not secret:
logger.error("Environment variable TIDAL_OAUTH_TOKENS not set")
return False
lines = secret.strip().splitlines()
if len(lines) != 4:
logger.error("TIDAL_OAUTH_TOKENS secret malformed: expected 4 lines")
return False
try:
expiry_time = datetime.fromisoformat(lines[3])
except Exception as e:
logger.error(f"Failed to parse expiry_time: {e}")
return False
session.load_oauth_session(lines[0], lines[1], lines[2], expiry_time)
logger.info("OAuth tokens loaded successfully from secret")
return True
session = tidalapi.Session()
if not load_tokens_from_secret(session):
raise RuntimeError("Failed to load OAuth tokens from secret")
if not session.check_login():
raise RuntimeError("Failed to login with saved tokens")
# --- Constants ---
QUALITY_MAP = {
"LOW": Quality.low_96k,
"HIGH": Quality.low_320k,
"LOSSLESS": Quality.high_lossless,
"HI_RES": Quality.hi_res_lossless,
}
# --- Helper Functions ---
def _extract_stream_info(track_id: int):
"""
Internal helper to fetch stream info for a specific track ID.
Returns a dictionary or raises an exception/returns None on failure.
"""
try:
track = Track(session, track_id)
# Verify track exists/can be fetched
_ = track.name
except Exception as e:
logger.error(f"Track lookup failed for ID {track_id}: {e}")
return None
try:
stream = track.get_stream()
manifest = stream.get_stream_manifest()
except Exception as e:
logger.error(f"Stream fetching failed for {track.name} ({track_id}): {e}")
return None
result = {
"track_id": track_id,
"track_name": track.name,
"artist_name": track.artist.name,
"album_name": track.album.name if track.album else "Unknown",
"audio_quality": str(stream.audio_quality),
"stream_type": None,
"download_urls": [],
"mpd_manifest": None
}
if stream.is_bts:
urls = manifest.get_urls()
if urls:
result["stream_type"] = "bts"
result["download_urls"] = urls
elif stream.is_mpd:
mpd_manifest = stream.get_manifest_data()
result["stream_type"] = "mpd"
result["mpd_manifest"] = mpd_manifest
else:
logger.warning(f"Unsupported stream type for track {track_id}")
return None
return result
# --- Endpoints ---
@app.get("/")
async def root():
logger.info("GET /")
return {
"message": "Internal Server Error",
"author": "made by Cody from chrunos.com"
}
@app.get("/search")
def search_tracks(
query: str = Query(..., description="Search query (e.g., song title)"),
limit: int = Query(10, description="Max results to return", ge=1, le=50),
offset: int = Query(0, description="Pagination offset", ge=0)
):
"""
Search for tracks on Tidal.
"""
logger.info(f"Searching for: {query} (limit={limit}, offset={offset})")
try:
# models=[Track] ensures we only get tracks back
search_results = session.search(query, models=[Track], limit=limit, offset=offset)
tracks = search_results.get('tracks', [])
results = []
for t in tracks:
results.append({
"id": t.id,
"title": t.name,
"artist": t.artist.name,
"album": t.album.name if t.album else None,
"duration": t.duration,
"explicit": t.explicit
})
return JSONResponse(content={"query": query, "count": len(results), "results": results})
except Exception as e:
logger.error(f"Search failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/playlist")
def get_playlist(
id: str = Query(..., description="Tidal Playlist UUID"),
quality: str = Query("HI_RES", description="Audio quality", pattern="^(LOW|HIGH|LOSSLESS|HI_RES)$"),
limit: int = Query(50, description="Max tracks to process (prevent timeouts)", ge=1, le=100),
offset: int = Query(0, description="Track offset", ge=0)
):
"""
Fetch a playlist and return download URLs for its tracks.
"""
logger.info(f"Request received for playlist: {id} with quality: {quality}")
if quality not in QUALITY_MAP:
raise HTTPException(status_code=400, detail=f"Invalid quality. Available: {list(QUALITY_MAP.keys())}")
session.audio_quality = QUALITY_MAP[quality]
try:
playlist = session.playlist(id)
# Fetch tracks from the playlist
tracks = playlist.tracks(limit=limit, offset=offset)
logger.info(f"Found {len(tracks)} tracks in playlist {playlist.name}")
except Exception as e:
logger.error(f"Playlist lookup failed: {e}")
raise HTTPException(status_code=404, detail="Playlist not found or API error")
processed_tracks = []
failed_tracks = []
for t in tracks:
# Reuse the helper to get stream info
stream_info = _extract_stream_info(t.id)
if stream_info:
processed_tracks.append(stream_info)
else:
failed_tracks.append({"id": t.id, "name": t.name})
return JSONResponse(content={
"playlist_id": id,
"playlist_name": playlist.name,
"processed_count": len(processed_tracks),
"failed_count": len(failed_tracks),
"tracks": processed_tracks,
"failed": failed_tracks
})
@app.get("/track/")
def get_track_download_url(
id: int = Query(..., description="TIDAL Track ID"),
quality: str = Query("HI_RES", description="Audio quality", pattern="^(LOW|HIGH|LOSSLESS|HI_RES)$"),
):
logger.info(f"Request received for track_id: {id} with quality: {quality}")
if quality not in QUALITY_MAP:
raise HTTPException(status_code=400, detail=f"Invalid quality. Available: {list(QUALITY_MAP.keys())}")
session.audio_quality = QUALITY_MAP[quality]
result = _extract_stream_info(id)
if not result:
raise HTTPException(status_code=404, detail="Track not found or no stream available")
return JSONResponse(content=result) |