Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 81 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -183,14 +187,17 @@ 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
if cache_key in banner_cache:
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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ uvicorn
PyYAML
pytest
httpx
requests
requests
pillow
104 changes: 104 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,122 @@ 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:
assert response.headers['content-type'] == 'image/jpeg'
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:
assert response.headers['content-type'] == 'image/jpeg'
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:
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
43 changes: 43 additions & 0 deletions utils/resize_image.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading