diff --git a/README.md b/README.md index 2752451..97ad27f 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,11 @@ Returns the full games database JSON. This is NOT RECOMMENDED due to the large s ### Game Assets -#### `GET api.nlib.cc/nx/[tid]/icon` -Returns the game icon (1024x1024 JPEG). +#### `GET api.nlib.cc/nx/[tid]/icon/[width]/[height]` +Returns the game icon. If `width` and `height` are not specified, the default size of 1024x1024 JPEG is returned. -#### `GET api.nlib.cc/nx/[tid]/banner` -Returns the game banner (1980x1080 JPEG). +#### `GET api.nlib.cc/nx/[tid]/banner/[width]/[height]` +Returns the game banner. Supported sizes are 1920x1080 and 1280x720. You can also use `/banner/720p` or `/banner/1080p`. If neither `width` nor `height` is specified, the default size of 1920x1080 JPEG is returned. #### `GET api.nlib.cc/nx/[tid]/screen/[screen_id]` Returns a specific screenshot of the game (JPEG). If `screen_id` is not specified, the first screenshot is returned by default. diff --git a/main.py b/main.py index e277358..4b28d40 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from time import time from collections import defaultdict +from utils.resize_image import resize_image, nearest_size import updater # Change directory to the main.py dir @@ -167,6 +168,9 @@ def get_game_icon(tid, size: tuple = (1024, 1024)): return icon_cache[cache_key] icon_path = os.path.join(config['database-path'], 'media', f'{tid}', 'icon.jpg') + icon_path = resize_image(icon_path, *size) + if not icon_path: + return None if os.path.exists(icon_path): with open(icon_path, 'rb') as file: icon = file.read() @@ -183,7 +187,7 @@ def get_game_icon(tid, size: tuple = (1024, 1024)): # Custom cache for game banners banner_cache = {} banner_cache_max_size = 128 -def get_game_banner(tid, size: tuple = (1980, 1080)): +def get_game_banner(tid, size: tuple = (1920, 1080)): cache_key = f"{tid}_{size}" # Check if result is in cache @@ -191,6 +195,9 @@ def get_game_banner(tid, size: tuple = (1980, 1080)): return banner_cache[cache_key] banner_path = os.path.join(config['database-path'], 'media', f'{tid}', 'banner.jpg') + banner_path = resize_image(banner_path, *size) + if not banner_path: + return None if os.path.exists(banner_path): with open(banner_path, 'rb') as file: banner = file.read() @@ -248,7 +255,9 @@ def format_json_dates(data: dict) -> dict: @app.get('/{platform}/{tid}/{asset_type}/') @app.get('/{platform}/{tid}/{asset_type}/{screen_id}') @app.get('/{platform}/{tid}/{asset_type}/{screen_id}/') -async def get_nx(platform: str, tid: str, asset_type: str = None, screen_id = 1): +@app.get('/{platform}/{tid}/{asset_type}/{screen_id}/{media_height}') +@app.get('/{platform}/{tid}/{asset_type}/{screen_id}/{media_height}/') +async def get_nx(platform: str, tid: str, asset_type: str = None, screen_id=1, media_height=None): if platform.lower() not in ['nx', 'switch']: raise HTTPException(status_code=404, detail=f"Platform {platform} not supported") @@ -299,20 +308,85 @@ async def get_nx(platform: str, tid: str, asset_type: str = None, screen_id = 1) # nx/0100A0D004FB0000/icon if asset_type == 'icon': # Handle icon request - content = get_game_icon(tid, size=(1024, 1024)) + try: + width = int(screen_id) + except ValueError: + raise HTTPException(status_code=422, detail="Width must be an integer") + height = media_height + if height: + try: + height = int(height) + except ValueError: + raise HTTPException(status_code=422, detail="Height must be an integer") + + # Determine the height if not provided + if width != 1 and not height: + height = width + if width == 1 and height: + height = 1 + if width != height: + width, height = 1024, 1024 + + # Ensure width and height are nearest valid sizes + width = nearest_size(width) + height = nearest_size(height) + + content = get_game_icon(tid, size=(width, height)) + if content: - return Response(content=content, media_type="image/jpeg") + headers = { + "Content-Type": "image/jpeg", + "Content-Width": str(width), + "Content-Height": str(height), + "Content-Disposition": f'inline; filename="icon_{width}x{height}.jpg"' + } + return Response(content=content, headers=headers) else: raise HTTPException(status_code=404, detail=f"Icon for {tid} not found") # nx/0100A0D004FB0000/banner if asset_type == 'banner': # Handle banner request - content = get_game_banner(tid, size=(1980, 1080)) + try: + if screen_id in ["720p", "1080p"]: + width = {"720p": 1280, "1080p": 1920}[screen_id] + height = {"720p": 720, "1080p": 1080}[screen_id] + else: + width = int(screen_id) + height = media_height + except ValueError: + raise HTTPException(status_code=422, detail="Width must be an integer or one of '720p' or '1080p'") + + if not height: + height = 1080 # Default height for banners + else: + try: + height = int(height) + except ValueError: + raise HTTPException(status_code=422, detail="Height must be an integer") + + # Ensure both width and height are provided + if width == 1: + width = 1920 # Default width for banners + + if width / height == 16 / 9: + content = get_game_banner(tid, size=(width, height)) + else: + raise HTTPException(status_code=422, detail="Width and height must maintain a 16:9 aspect ratio") + if content: - return Response(content=content, media_type="image/jpeg") + headers = { + "Content-Type": "image/jpeg", + "Content-Width": str(width), + "Content-Height": str(height), + "Content-Disposition": f'inline; filename="banner_{width}x{height}.jpg"' + } + return Response(content=content, headers=headers) else: - raise HTTPException(status_code=404, detail=f"Banner for {tid} not found") + if width != 1920 or height != 1080: + raise HTTPException(status_code=422, detail="Resolution not accepted.") + else: + raise HTTPException(status_code=404, detail=f"Banner for {tid} not found") # nx/0100A0D004FB0000/screen # nx/0100A0D004FB0000/screen/4 diff --git a/requirements.txt b/requirements.txt index a3b45a2..d258163 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn PyYAML pytest httpx -requests \ No newline at end of file +requests +pillow \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py index 573f677..b4a03c3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -31,6 +31,7 @@ def test_get_switch_without_asset_type(): assert response.status_code == 200 assert response.json().get("console") == "nx" + def test_get_nx_icon(): response = client.get(f"/nx/{GAME_ID}/icon") if response.status_code == 200: @@ -38,6 +39,59 @@ def test_get_nx_icon(): else: assert response.status_code == 404 +def test_get_nx_icon_custom_size(): + response = client.get(f"/nx/{GAME_ID}/icon/512") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '512' + else: + assert response.status_code == 404 + +def test_get_nx_icon_custom_width_height(): + response = client.get(f"/nx/{GAME_ID}/icon/512/512") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '512' + assert response.headers['content-height'] == '512' + else: + assert response.status_code == 404 + +def test_get_nx_icon_custom_dimension_512_500(): + response = client.get(f"/nx/{GAME_ID}/icon/512/500") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '1024' + assert response.headers['content-height'] == '1024' + else: + assert response.status_code == 404 + +def test_get_nx_icon_custom_dimension_1(): + response = client.get(f"/nx/{GAME_ID}/icon/1") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '1024' + assert response.headers['content-height'] == '1024' + else: + assert response.status_code == 404 + +def test_get_nx_icon_custom_dimension_1_1(): + response = client.get(f"/nx/{GAME_ID}/icon/1/1") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '8' + assert response.headers['content-height'] == '8' + else: + assert response.status_code == 404 + +def test_get_nx_icon_invalid_size(): + response = client.get(f"/nx/{GAME_ID}/icon/invalid") + assert response.status_code == 422 + +def test_get_nx_icon_invalid_width_height(): + response = client.get(f"/nx/{GAME_ID}/icon/512/invalid") + assert response.status_code == 422 + + def test_get_nx_banner(): response = client.get(f"/nx/{GAME_ID}/banner") if response.status_code == 200: @@ -45,6 +99,54 @@ def test_get_nx_banner(): else: assert response.status_code == 404 +def test_get_nx_banner_custom_size(): + response = client.get(f"/nx/{GAME_ID}/banner/1280/720") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '1280' + assert response.headers['content-height'] == '720' + else: + assert response.status_code == 404 + +def test_get_nx_banner_720p(): + response = client.get(f"/nx/{GAME_ID}/banner/720p") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '1280' + assert response.headers['content-height'] == '720' + else: + assert response.status_code == 404 + +def test_get_nx_banner_1080p(): + response = client.get(f"/nx/{GAME_ID}/banner/1080p") + if response.status_code == 200: + assert response.headers['content-type'] == 'image/jpeg' + assert response.headers['content-width'] == '1920' + assert response.headers['content-height'] == '1080' + else: + assert response.status_code == 404 + +def test_get_nx_banner_480p(): + response = client.get(f"/nx/{GAME_ID}/banner/480p") + assert response.status_code == 422 + +def test_get_nx_banner_invalid_resolution(): + response = client.get(f"/nx/{GAME_ID}/banner/16/9") + assert response.status_code == 422 + +def test_get_nx_banner_invalid_ratio(): + response = client.get(f"/nx/{GAME_ID}/banner/1280/1280") + assert response.status_code == 422 + +def test_get_nx_banner_invalid_size(): + response = client.get(f"/nx/{GAME_ID}/banner/invalid") + assert response.status_code == 422 + +def test_get_nx_banner_invalid_width_height(): + response = client.get(f"/nx/{GAME_ID}/banner/1280/invalid") + assert response.status_code == 422 + + def test_get_nx_screen(): response = client.get(f"/nx/{GAME_ID}/screen") if response.status_code == 200: @@ -65,6 +167,7 @@ def test_get_nx_screens(): assert "count" in response.json() assert "screenshots" in response.json() + def test_get_nx_full(): response = client.get("/nx/full") assert response.status_code == 200 @@ -75,6 +178,7 @@ def test_get_nx_all(): response = client.get("/nx/all") assert response.status_code == 200 + def test_get_nx_base(): response = client.get(f"/nx/BASE/{GAME_ID}") assert response.status_code == 200 diff --git a/updater.py b/updater.py index 30b9186..f259f56 100644 --- a/updater.py +++ b/updater.py @@ -121,7 +121,11 @@ def update_to_latest_release(tag_name): yaml.dump(existing_config, merged_file) else: # Overwrite other files - shutil.copy2(extracted_path, dest_path) + try: + shutil.copy2(extracted_path, dest_path) + except PermissionError: + print(f"Permission denied: {dest_path}. Skipping file.") + pass else: shutil.copy2(extracted_path, dest_path) diff --git a/utils/resize_image.py b/utils/resize_image.py new file mode 100644 index 0000000..c3dca0f --- /dev/null +++ b/utils/resize_image.py @@ -0,0 +1,43 @@ +from PIL import Image +import os + + +def nearest_size(size): + allowed_sizes = [8 * (2 ** i) for i in range(8)] # [8, 16, 32, 64, 128, 256, 512, 1024] + return min(allowed_sizes, key=lambda x: abs(x - size)) + +def resize_image(file_path: str, width: int, height: int) -> str: + base, ext = os.path.splitext(file_path) + new_file_path = f"{base}_{width}x{height}{ext}" + + # Default icon size + if width == 1024 and height == 1024: + return file_path + + # Default banner size + if width == 1920 and height == 1080: + return file_path + + if os.path.exists(new_file_path): + return new_file_path + + if not os.path.exists(file_path): + return None + + with Image.open(file_path) as img: + aspect_ratio = width / height + if (width == height and width == nearest_size(width)) or \ + (aspect_ratio == 16/9 and height in [720, 1080]): + resized_img = img.resize((width, height), Image.NEAREST) + resized_img.save(new_file_path) + else: + return None + + return new_file_path + + +# Testing +if __name__ == '__main__': + file_path = '/data/NX-DB/media/01002AA01C7C2000/icon.jpg' + new_file_path = resize_image(file_path, 100, 100) + print(f"Resized image saved to: {new_file_path}") \ No newline at end of file