File size: 14,923 Bytes
48a533f
d161c13
 
 
 
 
 
 
 
 
1059bd7
9d6531a
d161c13
48a533f
1059bd7
d161c13
52ee455
d161c13
 
 
52ee455
d161c13
 
 
 
 
 
 
 
 
9349eb1
d161c13
 
 
 
 
 
 
9349eb1
d161c13
 
 
9349eb1
d161c13
 
 
 
 
 
 
 
 
 
9349eb1
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9349eb1
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
9349eb1
d161c13
 
 
9349eb1
d161c13
 
1059bd7
9349eb1
d161c13
 
 
 
8d69ef7
 
d161c13
8d69ef7
 
 
 
d161c13
 
48a533f
d161c13
 
 
 
8d69ef7
d161c13
 
8d69ef7
 
 
 
 
 
 
 
d161c13
8d69ef7
 
 
 
 
 
 
d161c13
 
8d69ef7
d161c13
8d69ef7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d161c13
8d69ef7
d161c13
 
 
8d69ef7
d161c13
 
8d69ef7
d161c13
8d69ef7
 
 
 
d161c13
 
8d69ef7
 
d161c13
82c6cbd
94196f3
 
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9349eb1
d161c13
 
 
 
93af13e
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
52ee455
d161c13
 
 
 
94196f3
d161c13
 
 
 
 
 
 
 
754cbc8
d161c13
 
 
 
 
 
94196f3
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0532bd9
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94196f3
930d2f7
 
 
 
 
 
e659c3f
930d2f7
 
e659c3f
930d2f7
e659c3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94196f3
930d2f7
e659c3f
 
 
 
 
 
 
 
930d2f7
9349eb1
d161c13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059bd7
 
 
 
 
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
import os
import json
import time
import urllib.parse
from datetime import datetime, timezone
from starlette.responses import JSONResponse
from fastapi import FastAPI, HTTPException, status, Request
from yt_dlp import YoutubeDL
from yt_dlp.version import __version__ as yt_dlp_version
from typing import Union, Dict
import uvicorn

app = FastAPI(docs_url=None, redoc_url=None)

# Set cache directory to /tmp which is writable in Hugging Face containers
os.environ["XDG_CACHE_HOME"] = "/tmp"

# Rate limiting configuration
DAILY_LIMIT = 100  # Maximum requests per IP per day
RATE_LIMIT_FILE = "/tmp/rate_limits.json"

def load_rate_limits() -> Dict[str, Dict]:
    """Load rate limit data from file"""
    try:
        if os.path.exists(RATE_LIMIT_FILE):
            with open(RATE_LIMIT_FILE, 'r') as f:
                return json.load(f)
    except Exception:
        pass
    return {}

def save_rate_limits(rate_limits: Dict[str, Dict]):
    """Save rate limit data to file"""
    try:
        with open(RATE_LIMIT_FILE, 'w') as f:
            json.dump(rate_limits, f)
    except Exception:
        pass

def get_current_date() -> str:
    """Get current date as string in YYYY-MM-DD format"""
    return datetime.now(timezone.utc).strftime('%Y-%m-%d')

def cleanup_old_entries(rate_limits: Dict[str, Dict]) -> Dict[str, Dict]:
    """Remove entries older than today"""
    current_date = get_current_date()
    cleaned = {}
    
    for ip, data in rate_limits.items():
        if data.get('date') == current_date:
            cleaned[ip] = data
    
    return cleaned

def check_rate_limit(ip: str) -> tuple[bool, int]:
    """
    Check if IP has exceeded daily limit
    Returns: (is_allowed, remaining_requests)
    """
    rate_limits = load_rate_limits()
    rate_limits = cleanup_old_entries(rate_limits)
    
    current_date = get_current_date()
    
    if ip not in rate_limits:
        rate_limits[ip] = {
            'date': current_date,
            'count': 0
        }
    
    ip_data = rate_limits[ip]
    
    # Reset count if it's a new day
    if ip_data.get('date') != current_date:
        ip_data['date'] = current_date
        ip_data['count'] = 0
    
    current_count = ip_data['count']
    
    if current_count >= DAILY_LIMIT:
        return False, 0
    
    # Increment count
    ip_data['count'] = current_count + 1
    rate_limits[ip] = ip_data
    
    # Save updated limits
    save_rate_limits(rate_limits)
    
    remaining = DAILY_LIMIT - ip_data['count']
    return True, remaining

def get_client_ip(request: Request) -> str:
    """Extract client IP from request, handling proxies"""
    # Check for common proxy headers
    forwarded_for = request.headers.get("x-forwarded-for")
    if forwarded_for:
        # Take the first IP in the chain
        return forwarded_for.split(",")[0].strip()
    
    real_ip = request.headers.get("x-real-ip")
    if real_ip:
        return real_ip.strip()
    
    # Fallback to direct client IP
    return request.client.host if request.client else "unknown"

@app.get("/api/version")
async def version_info():
    return JSONResponse({"yt_dlp": yt_dlp_version})

@app.get('/')
def main():
    return "Chrunos Downloader API Is Running well on Hugging Face."

@app.get("/api/info")
async def get_info(
    request: Request,
    url: str, 
    quality: str = "1080",
    audio_only: bool = False  # <-- Added audio toggle
):
    """
    Resolves a video or audio URL and returns a simplified JSON payload.
    Auto-detects SoundCloud to prevent video-filter errors.
    """
    client_ip = get_client_ip(request)
    is_allowed, remaining = check_rate_limit(client_ip)
    
    if not is_allowed:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail=f"Daily limit of {DAILY_LIMIT} requests exceeded. Try again tomorrow.",
            headers={"X-RateLimit-Reset": str(int(time.time()) + 86400)}
        )
    
    # --- SMART FORMAT SELECTION ---
    # Auto-detect SoundCloud, or check if the client explicitly wants audio
    if "soundcloud.com" in url or audio_only:
        format_selector = "bestaudio/best"
    else:
        # Your custom H.264 first, AV1 second video logic
        format_selector = f"best[height<={quality}][vcodec^=avc][ext=mp4]/best[height<={quality}][vcodec^=av01][ext=mp4]/best[height<={quality}][ext=mp4]/bestvideo[height<={quality}]+bestaudio/best"

    ydl_options = {
        "format": format_selector,
        "quiet": True,
        "no_warnings": True,
        "skip_download": True,
        "noplaylist": True,
        "cachedir": "/tmp/yt-dlp-cache",
        "js-runtimes": "node"
    }
    
    with YoutubeDL(ydl_options) as ydl:
        try:
            info = ydl.extract_info(url, download=False)
            
            download_url = info.get("url")
            http_headers = info.get("http_headers", {})
            
            # Fallback for split formats (bestvideo+bestaudio)
            if not download_url and info.get("requested_formats"):
                # If audio_only is True, requested_formats[0] might be audio. 
                # If video, requested_formats[0] is video, [1] is audio.
                # We grab the first one to ensure we get a valid URL.
                fmt = info["requested_formats"][0] 
                download_url = fmt.get("url")
                http_headers = fmt.get("http_headers", http_headers)
                
            if not download_url:
                raise HTTPException(
                    status_code=400, 
                    detail="ダウンロードURLを取得できませんでした",
                    headers={"Cache-Control": "no-store, max-age=0"}
                )
                
            title = info.get("title", "audio" if audio_only else "video")
            ext = info.get("ext", "mp3" if audio_only else "mp4")
            filename = f"{title}.{ext}"
            filesize = info.get("filesize") or info.get("filesize_approx")
            
            response_data = {
                "status": "ok",
                "url": download_url,
                "title": title,
                "filename": filename,
                "ext": ext,
                "filesize": filesize,
                "headers": http_headers,
            }
            
            return JSONResponse(
                response_data,
                headers={
                    "Cache-Control": "s-maxage=2592000, stale-while-revalidate",
                    "X-RateLimit-Limit": str(DAILY_LIMIT),
                    "X-RateLimit-Remaining": str(remaining)
                }
            )
            
        except Exception as e:
            error_msg = str(e)
            if "DownloadError" in str(type(e)):
                error_msg = f"メディアの取得に失敗: {error_msg}" # Changed 'video' to 'media' in JP
            
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=error_msg,
                headers={"Cache-Control": "no-store, max-age=0"}
            )

import urllib.parse

@app.get("/api/playlist")
async def get_playlist_info(
    request: Request,
    url: str,
    start: int = 1,
    end: int = 50
):
    """
    Fetches paginated items from a playlist or user profile.
    Strictly enforces a maximum of 50 items per request and provides a next_page URL.
    """
    if start < 1:
        raise HTTPException(status_code=400, detail="'start' must be 1 or greater.")
    if end < start:
        raise HTTPException(status_code=400, detail="'end' must be greater than or equal to 'start'.")

    requested_count = end - start + 1
    if requested_count > 50:
        end = start + 49
        requested_count = 50

    client_ip = get_client_ip(request)
    is_allowed, remaining = check_rate_limit(client_ip)
    
    if not is_allowed:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail=f"Daily limit of {DAILY_LIMIT} requests exceeded. Try again tomorrow.",
            headers={
                "X-RateLimit-Limit": str(DAILY_LIMIT),
                "X-RateLimit-Remaining": "0",
                "X-RateLimit-Reset": str(int(time.time()) + 86400), 
                "Cache-Control": "no-store, max-age=0"
            }
        )
    
    ydl_options = {
        "retries": 3,
        "encoding": "utf8",
        "extract_flat": "in_playlist", 
        "dump_single_json": True,
        "ignoreerrors": True,
        "cachedir": "/tmp/yt-dlp-cache",
        "js-runtimes": "node",
        "playliststart": start,
        "playlistend": end
    }
    
    with YoutubeDL(ydl_options) as ytdl:
        try:
            response = ytdl.extract_info(url, download=False)
            if not response:
                raise HTTPException(status_code=404, detail="Playlist or profile not found.")
            
            raw_entries = response.get("entries") or []
            valid_entries = [e for e in raw_entries if e is not None]

            next_page_url = None
            if len(raw_entries) >= requested_count:
                next_start = end + 1
                next_end = next_start + 49
                encoded_url = urllib.parse.quote(url)
                base_url = str(request.base_url).rstrip('/')
                next_page_url = f"{base_url}/api/playlist?url={encoded_url}&start={next_start}&end={next_end}"

            clean_response = {
                "id": response.get("id"),
                "title": response.get("title", "Unknown Playlist"),
                "uploader": response.get("uploader"),
                "items_returned": len(valid_entries),
                "next_page": next_page_url,
                "entries": valid_entries
            }
            
            return JSONResponse(
                clean_response,
                headers={
                    "Cache-Control": "s-maxage=2592000, stale-while-revalidate",
                    "X-RateLimit-Limit": str(DAILY_LIMIT),
                    "X-RateLimit-Remaining": str(remaining),
                    "X-RateLimit-Reset": str(int(time.time()) + 86400)
                }
            )
        except Exception as e:
            print(f"Error extracting playlist: {e}")
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=repr(e),
                headers={"Cache-Control": "no-store, max-age=0"},
            )
            

import httpx

SOUNDCLOUD_CLIENT_ID = "khI8ciOiYPX6UVGInQY5zA0zvTkfzuuC"  # sniff from browser network tab on soundcloud.com

@app.get("/api/list")
async def get_sound_playlist_info(request: Request, url: str, start: int = 1, end: int = 50):
    
    limit = min(end - start + 1, 50)
    offset = start - 1

    async with httpx.AsyncClient(timeout=15) as client:
        
        # 1. Resolve URL
        resolve_res = await client.get(
            "https://api-v2.soundcloud.com/resolve",
            params={"url": url, "client_id": SOUNDCLOUD_CLIENT_ID}
        )
        if resolve_res.status_code != 200:
            raise HTTPException(status_code=404, detail=f"Could not resolve URL: {resolve_res.text}")
        
        data = resolve_res.json()

        # 2. Handle both playlists and user profiles
        if data.get("kind") == "playlist":
            playlist_id = data["id"]
            
            # Playlists return a `tracks` array but lazy tracks only have `id`
            # Collect IDs from the paginated slice first
            all_track_ids = [t["id"] for t in data.get("tracks", [])]
            paginated_ids = all_track_ids[offset: offset + limit]

            if not paginated_ids:
                return JSONResponse({"entries": [], "items_returned": 0})

            # 3. Fetch full track details in one batch request
            tracks_res = await client.get(
                "https://api-v2.soundcloud.com/tracks",
                params={
                    "ids": ",".join(str(i) for i in paginated_ids),
                    "client_id": SOUNDCLOUD_CLIENT_ID
                }
            )
            if tracks_res.status_code != 200:
                raise HTTPException(status_code=502, detail=f"Track fetch failed: {tracks_res.text}")
            
            tracks = tracks_res.json()

        elif data.get("kind") == "user":
            # For user profiles, fetch their tracks directly
            tracks_res = await client.get(
                f"https://api-v2.soundcloud.com/users/{data['id']}/tracks",
                params={
                    "client_id": SOUNDCLOUD_CLIENT_ID,
                    "limit": limit,
                    "offset": offset
                }
            )
            if tracks_res.status_code != 200:
                raise HTTPException(status_code=502, detail=f"Track fetch failed: {tracks_res.text}")
            
            tracks = tracks_res.json().get("collection", [])

        else:
            raise HTTPException(status_code=400, detail=f"Unsupported kind: {data.get('kind')}")

        entries = [
            {
                "id": t.get("id"),
                "title": t.get("title"),
                "url": t.get("permalink_url"),
                "duration": t.get("duration"),
                "uploader": t.get("user", {}).get("username"),
            }
            for t in tracks
        ]

        # Next page
        next_page_url = None
        total = data.get("track_count") or data.get("likes_count")
        if total and (offset + limit) < total:
            next_start = end + 1
            next_end = next_start + 49
            encoded_url = urllib.parse.quote(url)
            base_url = str(request.base_url).rstrip('/')
            next_page_url = f"{base_url}/api/list?url={encoded_url}&start={next_start}&end={next_end}"

        return JSONResponse({
            "id": data.get("id"),
            "title": data.get("title") or data.get("username"),
            "uploader": data.get("username") or data.get("uploader"),
            "items_returned": len(entries),
            "next_page": next_page_url,
            "entries": entries
        })


@app.get("/api/rate-limit-status")
async def get_rate_limit_status(request: Request):
    client_ip = get_client_ip(request)
    rate_limits = load_rate_limits()
    rate_limits = cleanup_old_entries(rate_limits)
    
    current_date = get_current_date()
    
    if client_ip in rate_limits and rate_limits[client_ip].get('date') == current_date:
        used = rate_limits[client_ip]['count']
        remaining = DAILY_LIMIT - used
    else:
        used = 0
        remaining = DAILY_LIMIT
    
    return JSONResponse({
        "daily_limit": DAILY_LIMIT,
        "used": used,
        "remaining": remaining,
        "reset_time": f"{current_date}T00:00:00Z"
    })

# Add the Hugging Face compatible execution block
if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=7860)