diff --git a/README.md b/README.md index c05b496..d0e8c53 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ A modern FastAPI-based backend providing various endpoints for media, authentica ## Overview This server is built with [FastAPI](https://fastapi.tiangolo.com/) and provides a comprehensive API for multiple services. API documentation is available in three formats: -- **Swagger UI**: [https://api.codey.lol/docs](https://api.codey.lol/docs) - Classic interactive API explorer with "Try it out" functionality -- **Scalar**: [https://api.codey.lol/scalar](https://api.codey.lol/scalar) - Modern, fast interactive API documentation (recommended) -- **ReDoc**: [https://api.codey.lol/redoc](https://api.codey.lol/redoc) - Clean, read-only documentation with better visual design +- **Swagger UI**: [https://api.codey.horse/docs](https://api.codey.horse/docs) - Classic interactive API explorer with "Try it out" functionality +- **Scalar**: [https://api.codey.horse/scalar](https://api.codey.horse/scalar) - Modern, fast interactive API documentation (recommended) +- **ReDoc**: [https://api.codey.horse/redoc](https://api.codey.horse/redoc) - Clean, read-only documentation with better visual design ## API Endpoints diff --git a/base.py b/base.py index 63951ea..8bed47a 100644 --- a/base.py +++ b/base.py @@ -77,7 +77,7 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="codey.lol API", + title="codey.horse API", version="1.0", contact={"name": "codey"}, redirect_slashes=False, @@ -90,13 +90,13 @@ app = FastAPI( util = importlib.import_module("util").Utilities(app, constants) origins = [ - "https://codey.lol", - "https://old.codey.lol", - "https://api.codey.lol", + "https://codey.horse", + "https://old.codey.horse", + "https://api.codey.horse", "https://status.boatson.boats", - "https://_new.codey.lol", + "https://_new.codey.horse", "http://localhost:4321", - "https://local.codey.lol:4321", + "https://local.codey.horse:4321", ] app.add_middleware( @@ -113,7 +113,7 @@ app.add_middleware( def scalar_docs(): # Replace default FastAPI favicon with site favicon html_response = get_scalar_api_reference( - openapi_url="/openapi.json", title="codey.lol API" + openapi_url="/openapi.json", title="codey.horse API" ) try: body = ( @@ -123,7 +123,7 @@ def scalar_docs(): ) body = body.replace( "https://fastapi.tiangolo.com/img/favicon.png", - "https://codey.lol/images/favicon.png", + "https://codey.horse/images/favicon.png", ) # Build fresh response so Content-Length matches modified body return HTMLResponse(content=body, status_code=html_response.status_code) @@ -135,7 +135,7 @@ def scalar_docs(): @app.get("/favicon.ico", include_in_schema=False) async def favicon(): """Redirect favicon requests to the site icon.""" - return RedirectResponse("https://codey.lol/images/favicon.png") + return RedirectResponse("https://codey.horse/images/favicon.png") """ diff --git a/endpoints/lyric_search.py b/endpoints/lyric_search.py index 683efc6..c06a7e1 100644 --- a/endpoints/lyric_search.py +++ b/endpoints/lyric_search.py @@ -4,6 +4,7 @@ import regex import aiosqlite as sqlite3 from fastapi import FastAPI, HTTPException, Depends from fastapi_throttle import RateLimiter +from fastapi.requests import Request from fastapi.responses import JSONResponse from typing import LiteralString, Optional, Union, Iterable from regex import Pattern @@ -128,7 +129,7 @@ class LyricSearch(FastAPI): return JSONResponse(content=[]) return JSONResponse(content=typeahead) - async def lyric_search_handler(self, data: ValidLyricRequest) -> JSONResponse: + async def lyric_search_handler(self, data: ValidLyricRequest, request: Request) -> JSONResponse: """ Search for lyrics. @@ -317,4 +318,13 @@ class LyricSearch(FastAPI): if not data.extra: result.pop("src") + # Check if the request is coming from the old domain + host = request.headers.get("host", "") + if "api.codey.lol" in host: + warning_message = "Warning: The API domain is moving to api.codey.horse. Please update your scripts to use the new domain." + if "lyrics" in result: + result["lyrics"] = f"{warning_message}
{result['lyrics']}" + elif "lrc" in result: + result["lrc"] = f"{warning_message}\n{result['lrc']}" + return JSONResponse(content=result) diff --git a/endpoints/radio.py b/endpoints/radio.py index 9a2a284..7c60b3d 100644 --- a/endpoints/radio.py +++ b/endpoints/radio.py @@ -357,7 +357,7 @@ class Radio(FastAPI): logging.debug("album_art_handler Exception: %s", str(e)) traceback.print_exc() return RedirectResponse( - url="https://codey.lol/images/radio_art_default.jpg", status_code=302 + url="https://codey.horse/images/radio_art_default.jpg", status_code=302 ) async def radio_now_playing(self, request: Request, diff --git a/endpoints/rip.py b/endpoints/rip.py index 4a293f8..001db3f 100644 --- a/endpoints/rip.py +++ b/endpoints/rip.py @@ -686,7 +686,10 @@ class RIP(FastAPI): self, video_id: str, request: Request, user=Depends(get_current_user) ) -> Response: """ - Download a video file. + Download a video file via streaming. + + Streams the video directly from the HLS source through ffmpeg + to the client without buffering the entire file to disk first. Parameters: - **video_id** (str): The Tidal video ID. @@ -694,10 +697,11 @@ class RIP(FastAPI): - **user**: Current user (dependency). Returns: - - **Response**: The video file as a streaming response. + - **Response**: Streaming video response. """ - from fastapi.responses import FileResponse - import os + from fastapi.responses import StreamingResponse + import asyncio + import re if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): raise HTTPException(status_code=403, detail="Insufficient permissions") @@ -710,32 +714,76 @@ class RIP(FastAPI): status_code=400, ) - # Get video metadata for filename - metadata = await self.trip_util.get_video_metadata(vid_id) + # Get video metadata and stream URL concurrently + metadata, stream_url = await asyncio.gather( + self.trip_util.get_video_metadata(vid_id), + self.trip_util.get_video_stream_url(vid_id), + ) + if not metadata: return JSONResponse( content={"error": "Video not found"}, status_code=404, ) - # Download the video - file_path = await self.trip_util.download_video(vid_id) - if not file_path or not os.path.exists(file_path): + if not stream_url: return JSONResponse( - content={"error": "Failed to download video"}, + content={"error": "Video stream not available"}, status_code=500, ) - # Generate a nice filename + # Build a safe filename artist = metadata.get("artist", "Unknown") title = metadata.get("title", f"video_{vid_id}") - # Sanitize filename - safe_filename = f"{artist} - {title}.mp4".replace("/", "-").replace("\\", "-") + safe_filename = re.sub(r'[<>:"|?*\x00-\x1F]', '', f"{artist} - {title}.mp4") + safe_filename = safe_filename.replace("/", "-").replace("\\", "-") - return FileResponse( - path=file_path, - filename=safe_filename, + async def stream_video(): + """Stream ffmpeg output directly to client.""" + cmd = [ + "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", "error", + "-analyzeduration", "10M", + "-probesize", "10M", + "-i", stream_url, + "-c:v", "copy", + "-c:a", "aac", + "-b:a", "256k", + "-af", "aresample=async=1:first_pts=0", + "-movflags", "frag_keyframe+empty_moov+faststart", + "-f", "mp4", + "pipe:1", + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + while True: + chunk = await proc.stdout.read(256 * 1024) # 256KB chunks + if not chunk: + break + yield chunk + finally: + if proc.returncode is None: + try: + proc.kill() + except Exception: + pass + await proc.wait() + + return StreamingResponse( + stream_video(), media_type="video/mp4", + headers={ + "Content-Disposition": f'attachment; filename="{safe_filename}"', + }, ) async def video_bulk_fetch_handler( diff --git a/util.py b/util.py index 2f946cd..63fdfb7 100644 --- a/util.py +++ b/util.py @@ -13,7 +13,7 @@ class Utilities: def __init__(self, app: FastAPI, constants): self.constants = constants - self.blocked_redirect_uri = "https://codey.lol" + self.blocked_redirect_uri = "https://codey.horse" self.app = app def get_blocked_response(self, path: Optional[str] = None):