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)