Create app.py
Browse files
app.py
ADDED
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import shutil
|
2 |
+
from fastapi import FastAPI, HTTPException, Request, Body
|
3 |
+
from deezspot.deezloader import DeeLogin
|
4 |
+
from deezspot.spotloader import SpoLogin
|
5 |
+
import requests
|
6 |
+
import os
|
7 |
+
import logging
|
8 |
+
import json
|
9 |
+
from typing import Optional
|
10 |
+
from fastapi.staticfiles import StaticFiles
|
11 |
+
from dotenv import load_dotenv
|
12 |
+
from pydantic import BaseModel, Field, HttpUrl
|
13 |
+
from urllib.parse import quote
|
14 |
+
from pathlib import Path
|
15 |
+
import uuid
|
16 |
+
from fastapi import BackgroundTasks
|
17 |
+
from collections import defaultdict
|
18 |
+
import time
|
19 |
+
from datetime import timedelta
|
20 |
+
import gc
|
21 |
+
from typing import Dict, Union, Literal
|
22 |
+
import urllib.parse
|
23 |
+
from fastapi.responses import JSONResponse
|
24 |
+
|
25 |
+
# Set up logging
|
26 |
+
logging.basicConfig(level=logging.INFO)
|
27 |
+
logger = logging.getLogger(__name__)
|
28 |
+
|
29 |
+
app = FastAPI(title="Deezer API")
|
30 |
+
# Load environment variables
|
31 |
+
load_dotenv()
|
32 |
+
|
33 |
+
# Mount a static files directory to serve downloaded files
|
34 |
+
|
35 |
+
os.makedirs("downloads", exist_ok=True)
|
36 |
+
app.mount("/downloads", StaticFiles(directory="downloads"), name="downloads")
|
37 |
+
|
38 |
+
# Deezer API base URL
|
39 |
+
DEEZER_API_URL = "https://api.deezer.com"
|
40 |
+
|
41 |
+
# Deezer ARL token (required for deezspot downloads)
|
42 |
+
ARL_TOKEN = os.getenv('ARL')
|
43 |
+
|
44 |
+
|
45 |
+
class DownloadRequest(BaseModel):
|
46 |
+
url: str
|
47 |
+
quality: str
|
48 |
+
arl: str
|
49 |
+
|
50 |
+
|
51 |
+
def convert_deezer_short_link_async(short_link: str) -> str:
|
52 |
+
try:
|
53 |
+
response = requests.get(short_link, allow_redirects=True)
|
54 |
+
return response.url
|
55 |
+
except requests.RequestException as e:
|
56 |
+
print(f"An error occurred: {e}")
|
57 |
+
return ""
|
58 |
+
|
59 |
+
@app.middleware("http")
|
60 |
+
async def log_errors_middleware(request: Request, call_next):
|
61 |
+
try:
|
62 |
+
return await call_next(request)
|
63 |
+
except Exception as e:
|
64 |
+
logger.error("An unhandled exception occurred!", exc_info=True)
|
65 |
+
return JSONResponse(status_code=500, content={'detail': 'Internal Server Error'})
|
66 |
+
|
67 |
+
|
68 |
+
@app.get("/")
|
69 |
+
def read_root():
|
70 |
+
return {"message": "running"}
|
71 |
+
|
72 |
+
|
73 |
+
# Helper function to get track info
|
74 |
+
def get_track_info(track_id: str):
|
75 |
+
try:
|
76 |
+
response = requests.get(f"{DEEZER_API_URL}/track/{track_id}")
|
77 |
+
if response.status_code != 200:
|
78 |
+
raise HTTPException(status_code=404, detail="Track not found")
|
79 |
+
return response.json()
|
80 |
+
except requests.exceptions.RequestException as e:
|
81 |
+
logger.error(f"Network error fetching track metadata: {e}")
|
82 |
+
raise HTTPException(status_code=500, detail=str(e))
|
83 |
+
except Exception as e:
|
84 |
+
logger.error(f"Error fetching track metadata: {e}")
|
85 |
+
raise HTTPException(status_code=500, detail=str(e))
|
86 |
+
|
87 |
+
|
88 |
+
# Fetch track metadata from Deezer API
|
89 |
+
@app.get("/track/{track_id}")
|
90 |
+
def get_track(track_id: str):
|
91 |
+
return get_track_info(track_id)
|
92 |
+
|
93 |
+
|
94 |
+
# Rate limiting dictionary
|
95 |
+
class RateLimiter:
|
96 |
+
def __init__(self, max_requests: int, time_window: timedelta):
|
97 |
+
self.max_requests = max_requests
|
98 |
+
self.time_window = time_window
|
99 |
+
self.requests: Dict[str, list] = defaultdict(list)
|
100 |
+
|
101 |
+
def _cleanup_old_requests(self, user_ip: str) -> None:
|
102 |
+
"""Remove requests that are outside the time window."""
|
103 |
+
current_time = time.time()
|
104 |
+
self.requests[user_ip] = [
|
105 |
+
timestamp for timestamp in self.requests[user_ip]
|
106 |
+
if current_time - timestamp < self.time_window.total_seconds()
|
107 |
+
]
|
108 |
+
|
109 |
+
def is_rate_limited(self, user_ip: str) -> bool:
|
110 |
+
"""Check if the user has exceeded their rate limit."""
|
111 |
+
self._cleanup_old_requests(user_ip)
|
112 |
+
|
113 |
+
# Get current count after cleanup
|
114 |
+
current_count = len(self.requests[user_ip])
|
115 |
+
|
116 |
+
# Add current request timestamp (incrementing the count)
|
117 |
+
current_time = time.time()
|
118 |
+
self.requests[user_ip].append(current_time)
|
119 |
+
|
120 |
+
# Check if user has exceeded the maximum requests
|
121 |
+
return (current_count + 1) > self.max_requests
|
122 |
+
|
123 |
+
def get_current_count(self, user_ip: str) -> int:
|
124 |
+
"""Get the current request count for an IP."""
|
125 |
+
self._cleanup_old_requests(user_ip)
|
126 |
+
return len(self.requests[user_ip])
|
127 |
+
|
128 |
+
|
129 |
+
# Initialize rate limiter with 25 requests per day
|
130 |
+
rate_limiter = RateLimiter(
|
131 |
+
max_requests=5,
|
132 |
+
time_window=timedelta(days=1)
|
133 |
+
)
|
134 |
+
|
135 |
+
|
136 |
+
def get_user_ip(request: Request) -> str:
|
137 |
+
"""Helper function to get user's IP address."""
|
138 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
139 |
+
if forwarded:
|
140 |
+
return forwarded.split(",")[0]
|
141 |
+
return request.client.host
|
142 |
+
|
143 |
+
|
144 |
+
# Download a track and return a download URL
|
145 |
+
@app.post("/download/track")
|
146 |
+
def download_track(request: Request, download_request: DownloadRequest):
|
147 |
+
try:
|
148 |
+
user_ip = get_user_ip(request)
|
149 |
+
|
150 |
+
if rate_limiter.is_rate_limited(user_ip):
|
151 |
+
current_count = rate_limiter.get_current_count(user_ip)
|
152 |
+
raise HTTPException(
|
153 |
+
status_code=429,
|
154 |
+
detail={
|
155 |
+
"detail": "You have exceeded the maximum number of requests per day. Please try again tomorrow or get the Premium version for unlimited downloads at https://chrunos.com/premium-shortcuts/.",
|
156 |
+
"help": "https://t.me/chrunoss"
|
157 |
+
}
|
158 |
+
)
|
159 |
+
if download_request.arl is None or download_request.arl.strip() == "":
|
160 |
+
ARL = ARL_TOKEN
|
161 |
+
else:
|
162 |
+
ARL = download_request.arl
|
163 |
+
logger.info(f'arl: {ARL}')
|
164 |
+
url = download_request.url
|
165 |
+
if 'dzr.page' in url or 'deezer.page' in url or 'link.deezer' in url:
|
166 |
+
url = convert_deezer_short_link_async(url)
|
167 |
+
quality = download_request.quality
|
168 |
+
dl = DeeLogin(arl=ARL)
|
169 |
+
logger.info(f'track_url: {url}')
|
170 |
+
|
171 |
+
if quality not in ["MP3_320", "MP3_128", "FLAC"]:
|
172 |
+
raise HTTPException(status_code=400, detail="Invalid quality specified")
|
173 |
+
|
174 |
+
# 提取 track_id (假设 url 格式为 https://api.deezer.com/track/{track_id})
|
175 |
+
track_id = url.split("/")[-1]
|
176 |
+
|
177 |
+
# Fetch track info
|
178 |
+
track_info = get_track_info(track_id)
|
179 |
+
track_link = track_info.get("link")
|
180 |
+
if not track_link:
|
181 |
+
raise HTTPException(status_code=404, detail="Track link not found")
|
182 |
+
|
183 |
+
# Sanitize filename
|
184 |
+
track_title = track_info.get("title", "track")
|
185 |
+
artist_name = track_info.get("artist", {}).get("name", "unknown")
|
186 |
+
file_extension = "flac" if quality == "FLAC" else "mp3"
|
187 |
+
expected_filename = f"{artist_name} - {track_title}.{file_extension}".replace("/", "_") # Sanitize filename
|
188 |
+
|
189 |
+
# Clear the downloads directory
|
190 |
+
for root, dirs, files in os.walk("downloads"):
|
191 |
+
for file in files:
|
192 |
+
os.remove(os.path.join(root, file))
|
193 |
+
for dir in dirs:
|
194 |
+
shutil.rmtree(os.path.join(root, dir))
|
195 |
+
|
196 |
+
# Download the track using deezspot
|
197 |
+
try:
|
198 |
+
# 下载文件的代码
|
199 |
+
dl.download_trackdee(
|
200 |
+
link_track=track_link,
|
201 |
+
output_dir="downloads",
|
202 |
+
quality_download=quality,
|
203 |
+
recursive_quality=False,
|
204 |
+
recursive_download=False
|
205 |
+
)
|
206 |
+
except Exception as e:
|
207 |
+
logger.error(f"Error downloading file: {e}")
|
208 |
+
raise HTTPException(status_code=500, detail="File download failed")
|
209 |
+
|
210 |
+
# Recursively search for the file in the downloads directory
|
211 |
+
filepath = None
|
212 |
+
for root, dirs, files in os.walk("downloads"):
|
213 |
+
for file in files:
|
214 |
+
if file.endswith(f'.{file_extension}'):
|
215 |
+
filepath = os.path.join(root, file)
|
216 |
+
break
|
217 |
+
if filepath:
|
218 |
+
break
|
219 |
+
|
220 |
+
if not filepath:
|
221 |
+
raise HTTPException(status_code=500, detail=f"{file_extension} file not found after download")
|
222 |
+
if filepath:
|
223 |
+
file_size = os.path.getsize(filepath)
|
224 |
+
logger.info(f"Downloaded file size: {file_size} bytes")
|
225 |
+
|
226 |
+
# Return the download URL
|
227 |
+
relative_path = quote(str(os.path.relpath(filepath, "downloads")))
|
228 |
+
# Remove spaces from the relative path
|
229 |
+
# Auto-detect base URL from request
|
230 |
+
base_url = str(request.base_url).rstrip('/')
|
231 |
+
download_url = f"{base_url}/downloads/{relative_path}"
|
232 |
+
logger.info(f"Download successful: {download_url}")
|
233 |
+
gc.collect()
|
234 |
+
return {"download_url": download_url, "requests_remaining": rate_limiter.max_requests - rate_limiter.get_current_count(user_ip)}
|
235 |
+
except Exception as e:
|
236 |
+
logger.error(f"Error downloading track: {e}")
|
237 |
+
raise HTTPException(status_code=500, detail=str(e))
|
238 |
+
|
239 |
+
|
240 |
+
|
241 |
+
# Pydantic model for album request
|
242 |
+
class AlbumRequest(BaseModel):
|
243 |
+
id: str
|
244 |
+
|
245 |
+
|
246 |
+
# Fetch album data
|
247 |
+
@app.post("/z_album")
|
248 |
+
def fetch_album(request: AlbumRequest):
|
249 |
+
album_id = request.id
|
250 |
+
try:
|
251 |
+
response = requests.get(f"{DEEZER_API_URL}/album/{album_id}")
|
252 |
+
response.raise_for_status()
|
253 |
+
album_data = response.json()
|
254 |
+
tracks = album_data.get("tracks", {}).get("data", [])
|
255 |
+
result = []
|
256 |
+
for track in tracks:
|
257 |
+
title = track.get("title")
|
258 |
+
link = track.get("link")
|
259 |
+
if title and link:
|
260 |
+
result.append({
|
261 |
+
"title": title,
|
262 |
+
"link": link
|
263 |
+
})
|
264 |
+
return result
|
265 |
+
except requests.exceptions.RequestException as e:
|
266 |
+
logger.error(f"Network error fetching album: {e}")
|
267 |
+
raise HTTPException(status_code=500, detail=str(e))
|
268 |
+
except Exception as e:
|
269 |
+
logger.error(f"Error fetching album: {e}")
|
270 |
+
raise HTTPException(status_code=500, detail=str(e))
|
271 |
+
|
272 |
+
|
273 |
+
# Pydantic model for playlist request
|
274 |
+
class PlaylistRequest(BaseModel):
|
275 |
+
id: str
|
276 |
+
|
277 |
+
|
278 |
+
# Fetch playlist data
|
279 |
+
@app.post("/z_playlist")
|
280 |
+
def fetch_playlist(request: PlaylistRequest):
|
281 |
+
playlist_id = request.id
|
282 |
+
try:
|
283 |
+
response = requests.get(f"{DEEZER_API_URL}/playlist/{playlist_id}")
|
284 |
+
response.raise_for_status()
|
285 |
+
playlist_data = response.json()
|
286 |
+
tracks = playlist_data.get("tracks", {}).get("data", [])
|
287 |
+
result = []
|
288 |
+
for track in tracks:
|
289 |
+
title = track.get("title")
|
290 |
+
link = track.get("link")
|
291 |
+
if title and link:
|
292 |
+
result.append({
|
293 |
+
"title": title,
|
294 |
+
"link": link
|
295 |
+
})
|
296 |
+
return result
|
297 |
+
except requests.exceptions.RequestException as e:
|
298 |
+
logger.error(f"Network error fetching album: {e}")
|
299 |
+
raise HTTPException(status_code=500, detail=str(e))
|
300 |
+
except Exception as e:
|
301 |
+
logger.error(f"Error fetching album: {e}")
|
302 |
+
raise HTTPException(status_code=500, detail=str(e))
|
303 |
+
|
304 |
+
|
305 |
+
|
306 |
+
# Search tracks using Deezer API
|
307 |
+
@app.get("/z_search")
|
308 |
+
def search_tracks(query: str, limit: Optional[int] = 10):
|
309 |
+
try:
|
310 |
+
response = requests.get(f"{DEEZER_API_URL}/search", params={"q": query, "limit": limit})
|
311 |
+
return response.json()
|
312 |
+
except requests.exceptions.RequestException as e:
|
313 |
+
logger.error(f"Network error searching tracks: {e}")
|
314 |
+
raise HTTPException(status_code=500, detail=str(e))
|
315 |
+
except Exception as e:
|
316 |
+
logger.error(f"Error searching tracks: {e}")
|
317 |
+
raise HTTPException(status_code=500, detail=str(e))
|
318 |
+
|
319 |
+
|
320 |
+
# --- Request Body Model ---
|
321 |
+
# Defines the expected structure and validation for the request body.
|
322 |
+
class SpotDlRequest(BaseModel):
|
323 |
+
url: HttpUrl = Field(..., description="The URL to be processed.")
|
324 |
+
quality: Literal["128", "320", "FLAC"] = Field(
|
325 |
+
...,
|
326 |
+
description="The desired quality. Currently, only '128' is supported for link generation."
|
327 |
+
)
|
328 |
+
|
329 |
+
# --- Response Body Model ---
|
330 |
+
# Defines the structure for a successful response.
|
331 |
+
class SpotDlResponse(BaseModel):
|
332 |
+
download_url: str
|
333 |
+
|
334 |
+
# --- Error Response Model ---
|
335 |
+
# Defines the structure for an error response.
|
336 |
+
class ErrorResponse(BaseModel):
|
337 |
+
detail: str
|
338 |
+
|