From d0a71ae5fe407f928d1a12cb010c33b9be3b8d69 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 3 Feb 2026 16:22:39 -0500 Subject: [PATCH 1/8] add new endpoints for pkgj --- backend/endpoints/feeds.py | 386 ++++++++++++++++++++++++++- backend/endpoints/responses/feeds.py | 40 +++ 2 files changed, 418 insertions(+), 8 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 8b1224307..50e981dc1 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -22,6 +22,11 @@ from endpoints.responses.feeds import ( PKGiFeedPS3ItemSchema, PKGiFeedPSPItemSchema, PKGiFeedPSVitaItemSchema, + PkgjPSPDlcsItemSchema, + PkgjPSPGamesItemSchema, + PkgjPSVDlcsItemSchema, + PkgjPSVGamesItemSchema, + PkgjPSXGamesItemSchema, TinfoilFeedFileSchema, TinfoilFeedSchema, TinfoilFeedTitleDBSchema, @@ -318,8 +323,20 @@ def pkgi_ps3_feed( ) # Format: contentid,type,name,description,rap,url,size,checksum - txt_line = f'{pkgi_item.contentid},{pkgi_item.type},"{pkgi_item.name}",{pkgi_item.description},{pkgi_item.rap},"{pkgi_item.url}",{pkgi_item.size},{pkgi_item.checksum}' - txt_lines.append(txt_line) + txt_lines.append( + ",".join( + [ + pkgi_item.contentid, + str(pkgi_item.type), + f'"{pkgi_item.name}"', + pkgi_item.description, + pkgi_item.rap, + f'"{pkgi_item.url}"', + str(pkgi_item.size), + pkgi_item.checksum, + ] + ) + ) txt_content = "\n".join(txt_lines) @@ -388,8 +405,20 @@ def pkgi_psvita_feed( ) # Format: contentid,flags,name,name2,zrif,url,size,checksum - txt_line = f'{pkgi_item.contentid},{pkgi_item.flags},"{pkgi_item.name}",{pkgi_item.name2},{pkgi_item.zrif},"{pkgi_item.url}",{pkgi_item.size},{pkgi_item.checksum}' - txt_lines.append(txt_line) + txt_lines.append( + ",".join( + [ + pkgi_item.contentid, + str(pkgi_item.flags), + f'"{pkgi_item.name}"', + pkgi_item.name2, + pkgi_item.zrif, + f'"{pkgi_item.url}"', + str(pkgi_item.size), + pkgi_item.checksum, + ] + ) + ) txt_content = "\n".join(txt_lines) @@ -460,8 +489,20 @@ def pkgi_psp_feed( ) # Format: contentid,type,name,description,rap,url,size,checksum - txt_line = f'{pkgi_item.contentid},{pkgi_item.type},"{pkgi_item.name}",{pkgi_item.description},{pkgi_item.rap},"{pkgi_item.url}",{pkgi_item.size},{pkgi_item.checksum}' - txt_lines.append(txt_line) + txt_lines.append( + ",".join( + [ + pkgi_item.contentid, + str(pkgi_item.type), + f'"{pkgi_item.name}"', + pkgi_item.description, + pkgi_item.rap, + f'"{pkgi_item.url}"', + str(pkgi_item.size), + pkgi_item.checksum, + ] + ) + ) txt_content = "\n".join(txt_lines) @@ -583,8 +624,21 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: ) # Format: title platform region version author download_url filename size box_art_url - txt_line = f"{kekatsu_item.title}\t{kekatsu_item.platform}\t{kekatsu_item.region}\t{kekatsu_item.version}\t{kekatsu_item.author}\t{kekatsu_item.download_url}\t{kekatsu_item.filename}\t{kekatsu_item.size}\t{kekatsu_item.box_art_url}" - txt_lines.append(txt_line) + txt_lines.append( + "\t".join( + [ + kekatsu_item.title, + kekatsu_item.platform, + kekatsu_item.region, + kekatsu_item.version, + kekatsu_item.author, + kekatsu_item.download_url, + kekatsu_item.filename, + str(kekatsu_item.size), + kekatsu_item.box_art_url, + ] + ) + ) txt_content = "\n".join(txt_lines) @@ -596,3 +650,319 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: "Cache-Control": "no-cache", }, ) + + +def _format_pkgj_datetime(value: datetime | None) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + return "" + + +@protected_route( + router.get, + "/pkgj/psp/games", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +def pkgj_psp_games_feed(request: Request) -> Response: + platform = db_platform_handler.get_platform_by_slug(UPS.PSP) + if not platform: + raise HTTPException( + status_code=404, detail="PlayStation Portable platform not found" + ) + + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) + txt_lines = [] + txt_lines.append( + "Title ID\tRegion\tType\tName\tPKG direct link\tContent ID\tLast Modification Date\tRAP\tDownload .RAP file\tFile Size\tSHA256" + ) + + for rom in roms: + download_url = generate_rom_download_url(request, rom) + last_modified = _format_pkgj_datetime(rom.updated_at) + + pkgj_item = PkgjPSPGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + type="PSP", + name=(rom.name or rom.fs_name_no_tags).strip(), + download_link=download_url, + content_id="", + last_modified=rom.updated_at, + rap="", + download_rap_file="", + file_size=rom.fs_size_bytes, + sha_256=rom.sha1_hash or "", + ) + + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.type, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return Response( + content="\n".join(txt_lines), + media_type="text/plain", + headers={ + "Content-Disposition": "filename=titles_pspgames.txt", + "Cache-Control": "no-cache", + }, + ) + + +@protected_route( + router.get, + "/pkgj/psp/dlc", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +def pkgj_psp_dlcs_feed(request: Request) -> Response: + platform = db_platform_handler.get_platform_by_slug(UPS.PSP) + if not platform: + raise HTTPException( + status_code=404, detail="PlayStation Portable platform not found" + ) + + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) + txt_lines = [] + txt_lines.append( + "Title ID\tRegion\tName\tPKG direct link\tContent ID\tLast Modification Date\tRAP\tDownload .RAP file\tFile Size\tSHA256" + ) + + for rom in roms: + download_url = generate_rom_download_url(request, rom) + last_modified = _format_pkgj_datetime(rom.updated_at) + + pkgj_item = PkgjPSPDlcsItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(rom.name or rom.fs_name_no_tags).strip(), + download_link=download_url, + content_id="", + last_modified=rom.updated_at, + rap="", + download_rap_file="", + file_size=rom.fs_size_bytes, + sha_256=rom.sha1_hash or "", + ) + + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return Response( + content="\n".join(txt_lines), + media_type="text/plain", + headers={ + "Content-Disposition": "filename=titles_pspdlcs", + "Cache-Control": "no-cache", + }, + ) + + +@protected_route( + router.get, + "/pkgj/psvita/games", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +def pkgj_psv_games_feed(request: Request) -> Response: + platform = db_platform_handler.get_platform_by_slug(UPS.PSVITA) + if not platform: + raise HTTPException( + status_code=404, detail="PlayStation Vita platform not found" + ) + + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) + txt_lines = [] + txt_lines.append( + "Title ID\tRegion\tName\tPKG direct link\tzRIF\tContent ID\tLast Modification Date\tOriginal Name\tFile Size\tSHA256\tRequired FW\tApp Version" + ) + + for rom in roms: + download_url = generate_rom_download_url(request, rom) + last_modified = _format_pkgj_datetime(rom.updated_at) + + pkgj_item = PkgjPSVGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(rom.name or rom.fs_name_no_tags).strip(), + download_link=download_url, + zrif="", + content_id="", + last_modified=rom.updated_at, + original_name="", + file_size=rom.fs_size_bytes, + sha_256=rom.sha1_hash or "", + required_fw="", + app_version="", + ) + + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + pkgj_item.required_fw, + pkgj_item.app_version, + ] + ) + ) + + return Response( + content="\n".join(txt_lines), + media_type="text/plain", + headers={ + "Content-Disposition": "filename=titles_psvgames.tsv", + "Cache-Control": "no-cache", + }, + ) + + +@protected_route( + router.get, + "/pkgj/psvita/dlc", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +def pkgj_psv_dlcs_feed(request: Request) -> Response: + platform = db_platform_handler.get_platform_by_slug(UPS.PSVITA) + if not platform: + raise HTTPException( + status_code=404, detail="PlayStation Vita platform not found" + ) + + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) + txt_lines = [] + txt_lines.append( + "Title ID\tRegion\tName\tPKG direct link\tzRIF\tContent ID\tLast Modification Date\tFile Size\tSHA256" + ) + + for rom in roms: + download_url = generate_rom_download_url(request, rom) + last_modified = _format_pkgj_datetime(rom.updated_at) + + pkgj_item = PkgjPSVDlcsItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(rom.name or rom.fs_name_no_tags).strip(), + download_link=download_url, + zrif="", + content_id="", + last_modified=rom.updated_at, + file_size=rom.fs_size_bytes, + sha_256=rom.sha1_hash or "", + ) + + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + last_modified, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return Response( + content="\n".join(txt_lines), + media_type="text/plain", + headers={ + "Content-Disposition": "filename=titles_psvdlcs.tsv", + "Cache-Control": "no-cache", + }, + ) + + +@protected_route( + router.get, + "/pkgj/psx/games", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +def pkgj_psx_games_feed(request: Request) -> Response: + platform = db_platform_handler.get_platform_by_slug(UPS.PSX) + if not platform: + raise HTTPException(status_code=404, detail="PlayStation platform not found") + + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) + txt_lines = [] + txt_lines.append( + "Title ID\tRegion\tName\tPKG direct link\tContent ID\tLast Modification Date\tOriginal Name\tFile Size\tSHA256" + ) + + for rom in roms: + download_url = generate_rom_download_url(request, rom) + last_modified = _format_pkgj_datetime(rom.updated_at) + + pkgj_item = PkgjPSXGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(rom.name or rom.fs_name_no_tags).strip(), + download_link=download_url, + content_id="", + last_modified=rom.updated_at, + original_name="", + file_size=rom.fs_size_bytes, + sha_256=rom.sha1_hash or "", + ) + + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return Response( + content="\n".join(txt_lines), + media_type="text/plain", + headers={ + "Content-Disposition": "filename=titles_psxgames.tsv", + "Cache-Control": "no-cache", + }, + ) diff --git a/backend/endpoints/responses/feeds.py b/backend/endpoints/responses/feeds.py index 1cb6236f0..b9240be3a 100644 --- a/backend/endpoints/responses/feeds.py +++ b/backend/endpoints/responses/feeds.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Annotated, Any, Final, NotRequired, TypedDict from pydantic import BaseModel, BeforeValidator, Field, field_validator @@ -244,3 +245,42 @@ class KekatsuDSItemSchema(BaseModel): filename: str size: int box_art_url: str + + +# Pkgj feed formats +# Source: https://github.com/rommapp/romm/issues/2899 +class PkgjBaseItemSchema(BaseModel): + title_id: str + region: str + name: str + download_link: str + content_id: str + last_modified: datetime + file_size: int + sha_256: str + + +class PkgjPSPGamesItemSchema(PkgjBaseItemSchema): + type: str + rap: str + download_rap_file: str + + +class PkgjPSPDlcsItemSchema(PkgjBaseItemSchema): + rap: str + download_rap_file: str + + +class PkgjPSVGamesItemSchema(PkgjBaseItemSchema): + zrif: str + original_name: str + required_fw: str + app_version: str + + +class PkgjPSVDlcsItemSchema(PkgjBaseItemSchema): + zrif: str + + +class PkgjPSXGamesItemSchema(PkgjBaseItemSchema): + original_name: str From 4b4a34631d9d27b448c2e49ffe443710f478e457 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 3 Feb 2026 16:48:58 -0500 Subject: [PATCH 2/8] bot created test file --- backend/endpoints/feeds.py | 10 +- backend/tests/endpoints/feeds.py | 439 +++++++++++++++++++++++++++++++ 2 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 backend/tests/endpoints/feeds.py diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 50e981dc1..1dd29bef9 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -716,7 +716,7 @@ def pkgj_psp_games_feed(request: Request) -> Response: content="\n".join(txt_lines), media_type="text/plain", headers={ - "Content-Disposition": "filename=titles_pspgames.txt", + "Content-Disposition": "filename=pkgj_psp_games.txt", "Cache-Control": "no-cache", }, ) @@ -778,7 +778,7 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: content="\n".join(txt_lines), media_type="text/plain", headers={ - "Content-Disposition": "filename=titles_pspdlcs", + "Content-Disposition": "filename=pkgj_psp_dlc.txt", "Cache-Control": "no-cache", }, ) @@ -844,7 +844,7 @@ def pkgj_psv_games_feed(request: Request) -> Response: content="\n".join(txt_lines), media_type="text/plain", headers={ - "Content-Disposition": "filename=titles_psvgames.tsv", + "Content-Disposition": "filename=pkgj_psvita_games.txt", "Cache-Control": "no-cache", }, ) @@ -904,7 +904,7 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: content="\n".join(txt_lines), media_type="text/plain", headers={ - "Content-Disposition": "filename=titles_psvdlcs.tsv", + "Content-Disposition": "filename=pkgj_psvita_dlc.txt", "Cache-Control": "no-cache", }, ) @@ -962,7 +962,7 @@ def pkgj_psx_games_feed(request: Request) -> Response: content="\n".join(txt_lines), media_type="text/plain", headers={ - "Content-Disposition": "filename=titles_psxgames.tsv", + "Content-Disposition": "filename=pkgj_psx_games.txt", "Cache-Control": "no-cache", }, ) diff --git a/backend/tests/endpoints/feeds.py b/backend/tests/endpoints/feeds.py new file mode 100644 index 000000000..ded83b006 --- /dev/null +++ b/backend/tests/endpoints/feeds.py @@ -0,0 +1,439 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from main import app + +from handler.database import db_platform_handler, db_rom_handler +from handler.metadata.base_handler import UniversalPlatformSlug as UPS +from models.platform import Platform +from models.rom import Rom, RomFile, RomFileCategory, RomMetadata + + +@pytest.fixture +def client(): + with TestClient(app) as client: + yield client + + +def test_webrcade_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "Nintendo Entertainment System", "slug": UPS.NES, "fs_slug": UPS.NES}, + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Super Test Bros", + "fs_name": "Super Test Bros.zip", + "fs_name_no_tags": "Super Test Bros", + "fs_name_no_ext": "Super Test Bros", + "fs_extension": "zip", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/webrcade", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["title"] == "RomM Feed" + assert len(body["categories"]) == 1 + assert body["categories"][0]["title"] == platform.name + assert len(body["categories"][0]["items"]) == 1 + + +def test_tinfoil_feed(client: TestClient, platform: Platform, rom: Rom): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "Nintendo Switch", "slug": UPS.SWITCH, "fs_slug": UPS.SWITCH}, + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test Switch", + "fs_name": "Test Switch.nsp", + "fs_name_no_tags": "Test Switch", + "fs_name_no_ext": "Test Switch", + "fs_extension": "nsp", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name="Test Switch.nsp", + file_path=rom.fs_path, + file_size_bytes=456, + sha1_hash="beadfeed", + ) + ) + + response = client.get("/api/feeds/tinfoil?slug=switch") + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert len(body["files"]) == 1 + assert body["files"][0]["size"] > 0 + + +def test_pkgi_ps3_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, {"name": "PlayStation 3", "slug": UPS.PS3, "fs_slug": UPS.PS3} + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PS3", + "fs_name": "Test PS3.pkg", + "fs_name_no_tags": "Test PS3", + "fs_name_no_ext": "Test PS3", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name="Test PS3.pkg", + file_path=rom.fs_path, + file_size_bytes=456, + sha1_hash="beadfeed", + category=RomFileCategory.GAME, + ) + ) + + response = client.get( + "/api/feeds/pkgi/ps3/game", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgi_game.txt" + assert "Test PS3.pkg" in response.text + + +def test_pkgi_psvita_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Vita", "slug": UPS.PSVITA, "fs_slug": UPS.PSVITA}, + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSV", + "fs_name": "Test PSV.pkg", + "fs_name_no_tags": "Test PSV", + "fs_name_no_ext": "Test PSV", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name="Test PSV.pkg", + file_path=rom.fs_path, + file_size_bytes=456, + sha1_hash="beadfeed", + category=RomFileCategory.GAME, + ) + ) + + response = client.get( + "/api/feeds/pkgi/psvita/game", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgi_game.txt" + assert "Test PSV.pkg" in response.text + + +def test_pkgi_psp_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Portable", "slug": UPS.PSP, "fs_slug": UPS.PSP}, + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSP", + "fs_name": "Test PSP.pkg", + "fs_name_no_tags": "Test PSP", + "fs_name_no_ext": "Test PSP", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name="Test PSP.pkg", + file_path=rom.fs_path, + file_size_bytes=456, + sha1_hash="beadfeed", + category=RomFileCategory.GAME, + ) + ) + + response = client.get( + "/api/feeds/pkgi/psp/game", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgi_game.txt" + assert "Test PSP.pkg" in response.text + + +def test_fpkgi_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, {"name": "PlayStation 4", "slug": UPS.PS4, "fs_slug": UPS.PS4} + ) + rom = db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PS4", + "fs_name": "Test PS4.pkg", + "fs_name_no_tags": "Test PS4", + "fs_name_no_ext": "Test PS4", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + rom.metadatum = RomMetadata() + db_rom_handler.add_rom(rom) + + response = client.get( + "/api/feeds/fpkgi/ps4", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert "DATA" in body + assert len(body["DATA"]) == 1 + + +def test_kekatsu_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, {"name": "Nintendo DS", "slug": UPS.NDS, "fs_slug": UPS.NDS} + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test DS", + "fs_name": "Test DS.nds", + "fs_name_no_tags": "Test DS", + "fs_name_no_ext": "Test DS", + "fs_extension": "nds", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/kekatsu/nds", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.text.startswith("1") + assert "Test DS" in response.text + + +def test_pkgj_psp_games_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Portable", "slug": UPS.PSP, "fs_slug": UPS.PSP}, + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSP Game", + "fs_name": "Test PSP Game.pkg", + "fs_name_no_tags": "Test PSP Game", + "fs_name_no_ext": "Test PSP Game", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/pkgj/psp/games", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgj_psp_games.txt" + assert "Test PSP Game" in response.text + + +def test_pkgj_psp_dlc_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Portable", "slug": UPS.PSP, "fs_slug": UPS.PSP}, + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSP DLC", + "fs_name": "Test PSP DLC.pkg", + "fs_name_no_tags": "Test PSP DLC", + "fs_name_no_ext": "Test PSP DLC", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/pkgj/psp/dlc", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgj_psp_dlc.txt" + assert "Test PSP DLC" in response.text + + +def test_pkgj_psvita_games_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Vita", "slug": UPS.PSVITA, "fs_slug": UPS.PSVITA}, + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSV Game", + "fs_name": "Test PSV Game.pkg", + "fs_name_no_tags": "Test PSV Game", + "fs_name_no_ext": "Test PSV Game", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/pkgj/psvita/games", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgj_psvita_games.txt" + assert "Test PSV Game" in response.text + + +def test_pkgj_psvita_dlc_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, + {"name": "PlayStation Vita", "slug": UPS.PSVITA, "fs_slug": UPS.PSVITA}, + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSV DLC", + "fs_name": "Test PSV DLC.pkg", + "fs_name_no_tags": "Test PSV DLC", + "fs_name_no_ext": "Test PSV DLC", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/pkgj/psvita/dlc", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgj_psvita_dlc.txt" + assert "Test PSV DLC" in response.text + + +def test_pkgj_psx_games_feed( + client: TestClient, access_token: str, platform: Platform, rom: Rom +): + platform = db_platform_handler.update_platform( + platform.id, {"name": "PlayStation", "slug": UPS.PSX, "fs_slug": UPS.PSX} + ) + db_rom_handler.update_rom( + rom.id, + { + "platform_id": platform.id, + "name": "Test PSX Game", + "fs_name": "Test PSX Game.pkg", + "fs_name_no_tags": "Test PSX Game", + "fs_name_no_ext": "Test PSX Game", + "fs_extension": "pkg", + "fs_path": f"{platform.slug}/roms", + "fs_size_bytes": 123, + "sha1_hash": "deadbeef", + "regions": ["US"], + }, + ) + + response = client.get( + "/api/feeds/pkgj/psx/games", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"] == "filename=pkgj_psx_games.txt" + assert "Test PSX Game" in response.text From 959c9ac0bf63be61cc947cdba658c726fae854ec Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 3 Feb 2026 17:09:21 -0500 Subject: [PATCH 3/8] refactor feeds file --- backend/endpoints/feeds.py | 100 ++++-------------- .../romm_test/library/ps3/roms/Test PS3.pkg | 0 .../romm_test/library/psp/roms/Test PSP.pkg | 0 .../library/psvita/roms/Test PSV.pkg | 0 backend/tests/endpoints/feeds.py | 8 +- 5 files changed, 23 insertions(+), 85 deletions(-) create mode 100644 backend/romm_test/library/ps3/roms/Test PS3.pkg create mode 100644 backend/romm_test/library/psp/roms/Test PSP.pkg create mode 100644 backend/romm_test/library/psvita/roms/Test PSV.pkg diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 1dd29bef9..19426fac0 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -268,6 +268,17 @@ def generate_content_id(file: RomFile) -> str: return f"UP9644-{file.id:09d}_00-0000000000000000" +def _text_response(lines: list[str], filename: str) -> Response: + return Response( + content="\n".join(lines), + media_type="text/plain", + headers={ + "Content-Disposition": f"filename={filename}", + "Cache-Control": "no-cache", + }, + ) + + @protected_route( router.get, "/pkgi/ps3/{content_type}", @@ -338,16 +349,7 @@ def pkgi_ps3_feed( ) ) - txt_content = "\n".join(txt_lines) - - return Response( - content=txt_content, - media_type="text/plain", - headers={ - "Content-Disposition": f"filename=pkgi_{content_type_enum.value}.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @protected_route( @@ -420,16 +422,7 @@ def pkgi_psvita_feed( ) ) - txt_content = "\n".join(txt_lines) - - return Response( - content=txt_content, - media_type="text/plain", - headers={ - "Content-Disposition": f"filename=pkgi_{content_type_enum.value}.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @protected_route( @@ -504,16 +497,7 @@ def pkgi_psp_feed( ) ) - txt_content = "\n".join(txt_lines) - - return Response( - content=txt_content, - media_type="text/plain", - headers={ - "Content-Disposition": f"filename=pkgi_{content_type_enum.value}.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") def _format_release_date(timestamp: int | None) -> str | None: @@ -640,16 +624,7 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: ) ) - txt_content = "\n".join(txt_lines) - - return Response( - content=txt_content, - media_type="text/plain", - headers={ - "Content-Disposition": f"filename=kekatsu_{platform_slug}.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, f"kekatsu_{platform_slug}.txt") def _format_pkgj_datetime(value: datetime | None) -> str: @@ -712,14 +687,7 @@ def pkgj_psp_games_feed(request: Request) -> Response: ) ) - return Response( - content="\n".join(txt_lines), - media_type="text/plain", - headers={ - "Content-Disposition": "filename=pkgj_psp_games.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, "pkgj_psp_games.txt") @protected_route( @@ -774,14 +742,7 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: ) ) - return Response( - content="\n".join(txt_lines), - media_type="text/plain", - headers={ - "Content-Disposition": "filename=pkgj_psp_dlc.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, "pkgj_psp_dlc.txt") @protected_route( @@ -840,14 +801,7 @@ def pkgj_psv_games_feed(request: Request) -> Response: ) ) - return Response( - content="\n".join(txt_lines), - media_type="text/plain", - headers={ - "Content-Disposition": "filename=pkgj_psvita_games.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, "pkgj_psvita_games.txt") @protected_route( @@ -900,14 +854,7 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: ) ) - return Response( - content="\n".join(txt_lines), - media_type="text/plain", - headers={ - "Content-Disposition": "filename=pkgj_psvita_dlc.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, "pkgj_psvita_dlc.txt") @protected_route( @@ -958,11 +905,4 @@ def pkgj_psx_games_feed(request: Request) -> Response: ) ) - return Response( - content="\n".join(txt_lines), - media_type="text/plain", - headers={ - "Content-Disposition": "filename=pkgj_psx_games.txt", - "Cache-Control": "no-cache", - }, - ) + return _text_response(txt_lines, "pkgj_psx_games.txt") diff --git a/backend/romm_test/library/ps3/roms/Test PS3.pkg b/backend/romm_test/library/ps3/roms/Test PS3.pkg new file mode 100644 index 000000000..e69de29bb diff --git a/backend/romm_test/library/psp/roms/Test PSP.pkg b/backend/romm_test/library/psp/roms/Test PSP.pkg new file mode 100644 index 000000000..e69de29bb diff --git a/backend/romm_test/library/psvita/roms/Test PSV.pkg b/backend/romm_test/library/psvita/roms/Test PSV.pkg new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/endpoints/feeds.py b/backend/tests/endpoints/feeds.py index ded83b006..0066ba071 100644 --- a/backend/tests/endpoints/feeds.py +++ b/backend/tests/endpoints/feeds.py @@ -127,7 +127,7 @@ def test_pkgi_ps3_feed( ) assert response.status_code == status.HTTP_200_OK assert response.headers["content-disposition"] == "filename=pkgi_game.txt" - assert "Test PS3.pkg" in response.text + assert "Test PS3" in response.text def test_pkgi_psvita_feed( @@ -169,7 +169,7 @@ def test_pkgi_psvita_feed( ) assert response.status_code == status.HTTP_200_OK assert response.headers["content-disposition"] == "filename=pkgi_game.txt" - assert "Test PSV.pkg" in response.text + assert "Test PSV" in response.text def test_pkgi_psp_feed( @@ -211,7 +211,7 @@ def test_pkgi_psp_feed( ) assert response.status_code == status.HTTP_200_OK assert response.headers["content-disposition"] == "filename=pkgi_game.txt" - assert "Test PSP.pkg" in response.text + assert "Test PSP" in response.text def test_fpkgi_feed( @@ -235,8 +235,6 @@ def test_fpkgi_feed( "regions": ["US"], }, ) - rom.metadatum = RomMetadata() - db_rom_handler.add_rom(rom) response = client.get( "/api/feeds/fpkgi/ps4", From 393375be7f8ef4219d421a8114ba890fd554cf4b Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Mon, 9 Feb 2026 23:20:04 -0500 Subject: [PATCH 4/8] tweaks to returned feed data --- backend/endpoints/feeds.py | 374 ++++++++++++++++++++----------------- 1 file changed, 207 insertions(+), 167 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 19426fac0..738e8d366 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -268,7 +268,24 @@ def generate_content_id(file: RomFile) -> str: return f"UP9644-{file.id:09d}_00-0000000000000000" -def _text_response(lines: list[str], filename: str) -> Response: +def get_rap_data(request: Request, rom: Rom) -> tuple[str, str]: + """Helper to find the .rap file for a given rom""" + for file in rom.files: + if file.file_extension.lower() == "rap": + rap_hash = file.sha1_hash or "" + rap_download_url = generate_romfile_download_url(request, file) + return rap_hash, rap_download_url + + return "", "" + + +def format_pkgj_datetime(value: datetime | None) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + return "" + + +def text_response(lines: list[str], filename: str) -> Response: return Response( content="\n".join(lines), media_type="text/plain", @@ -320,6 +337,7 @@ def pkgi_ps3_feed( content_id = generate_content_id(file) download_url = generate_romfile_download_url(request, file) + rap_hash, _ = get_rap_data(request, rom) # Validate the item schema pkgi_item = PKGiFeedPS3ItemSchema( @@ -327,7 +345,7 @@ def pkgi_ps3_feed( type=content_type_int, name=file.file_name_no_tags, description="", - rap="", + rap=rap_hash, url=download_url, size=file.file_size_bytes, checksum=file.sha1_hash or "", @@ -349,7 +367,7 @@ def pkgi_ps3_feed( ) ) - return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") + return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @protected_route( @@ -422,7 +440,7 @@ def pkgi_psvita_feed( ) ) - return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") + return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @protected_route( @@ -468,6 +486,7 @@ def pkgi_psp_feed( content_id = generate_content_id(file) download_url = generate_romfile_download_url(request, file) + rap_hash, _ = get_rap_data(request, rom) # Validate the item schema pkgi_item = PKGiFeedPSPItemSchema( @@ -475,7 +494,7 @@ def pkgi_psp_feed( type=content_type_int, name=file.file_name_no_tags, description="", - rap="", + rap=rap_hash, url=download_url, size=file.file_size_bytes, checksum=file.sha1_hash or "", @@ -497,10 +516,10 @@ def pkgi_psp_feed( ) ) - return _text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") + return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") -def _format_release_date(timestamp: int | None) -> str | None: +def format_release_date(timestamp: int | None) -> str | None: """Format release date to MM-DD-YYYY format""" if not timestamp: return None @@ -541,7 +560,7 @@ def fpkgi_feed(request: Request, platform_slug: str) -> Response: title_id=f"ROMM{str(rom.id)[-5:].zfill(5)}", region=rom.regions[0] if rom.regions else None, version=rom.revision or None, - release=_format_release_date(rom.metadatum.first_release_date), + release=format_release_date(rom.metadatum.first_release_date), min_fw=None, cover_url=str( URLPath(rom.path_cover_large).make_absolute_url(request.base_url) @@ -624,13 +643,7 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: ) ) - return _text_response(txt_lines, f"kekatsu_{platform_slug}.txt") - - -def _format_pkgj_datetime(value: datetime | None) -> str: - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S") - return "" + return text_response(txt_lines, f"kekatsu_{platform_slug}.txt") @protected_route( @@ -652,42 +665,48 @@ def pkgj_psp_games_feed(request: Request) -> Response: ) for rom in roms: - download_url = generate_rom_download_url(request, rom) - last_modified = _format_pkgj_datetime(rom.updated_at) + for file in rom.files: + if not validate_pkgi_file(file, RomFileCategory.GAME): + continue - pkgj_item = PkgjPSPGamesItemSchema( - title_id="", - region=rom.regions[0] if rom.regions else "", - type="PSP", - name=(rom.name or rom.fs_name_no_tags).strip(), - download_link=download_url, - content_id="", - last_modified=rom.updated_at, - rap="", - download_rap_file="", - file_size=rom.fs_size_bytes, - sha_256=rom.sha1_hash or "", - ) + download_url = generate_romfile_download_url(request, file) + content_id = generate_content_id(file) + last_modified = format_pkgj_datetime(file.updated_at) + rap_hash, rap_download_url = get_rap_data(request, rom) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.type, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - last_modified, - pkgj_item.rap, - pkgj_item.download_rap_file, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] + pkgj_item = PkgjPSPGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + type="PSP", + name=(file.file_name_no_tags).strip(), + download_link=download_url, + content_id=content_id, + last_modified=file.updated_at, + rap=rap_hash, + download_rap_file=rap_download_url, + file_size=file.file_size_bytes, + sha_256=file.sha1_hash or "", ) - ) - return _text_response(txt_lines, "pkgj_psp_games.txt") + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.type, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return text_response(txt_lines, "pkgj_psp_games.txt") @protected_route( @@ -709,40 +728,46 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: ) for rom in roms: - download_url = generate_rom_download_url(request, rom) - last_modified = _format_pkgj_datetime(rom.updated_at) + for file in rom.files: + if not validate_pkgi_file(file, RomFileCategory.DLC): + continue - pkgj_item = PkgjPSPDlcsItemSchema( - title_id="", - region=rom.regions[0] if rom.regions else "", - name=(rom.name or rom.fs_name_no_tags).strip(), - download_link=download_url, - content_id="", - last_modified=rom.updated_at, - rap="", - download_rap_file="", - file_size=rom.fs_size_bytes, - sha_256=rom.sha1_hash or "", - ) + download_url = generate_romfile_download_url(request, file) + content_id = generate_content_id(file) + last_modified = format_pkgj_datetime(file.updated_at) + rap_hash, rap_download_url = get_rap_data(request, rom) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - last_modified, - pkgj_item.rap, - pkgj_item.download_rap_file, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] + pkgj_item = PkgjPSPDlcsItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(file.file_name_no_tags).strip(), + download_link=download_url, + content_id=content_id, + last_modified=file.updated_at, + rap=rap_hash, + download_rap_file=rap_download_url, + file_size=file.file_size_bytes, + sha_256=file.sha1_hash or "", ) - ) - return _text_response(txt_lines, "pkgj_psp_dlc.txt") + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return text_response(txt_lines, "pkgj_psp_dlc.txt") @protected_route( @@ -764,44 +789,49 @@ def pkgj_psv_games_feed(request: Request) -> Response: ) for rom in roms: - download_url = generate_rom_download_url(request, rom) - last_modified = _format_pkgj_datetime(rom.updated_at) + for file in rom.files: + if not validate_pkgi_file(file, RomFileCategory.GAME): + continue - pkgj_item = PkgjPSVGamesItemSchema( - title_id="", - region=rom.regions[0] if rom.regions else "", - name=(rom.name or rom.fs_name_no_tags).strip(), - download_link=download_url, - zrif="", - content_id="", - last_modified=rom.updated_at, - original_name="", - file_size=rom.fs_size_bytes, - sha_256=rom.sha1_hash or "", - required_fw="", - app_version="", - ) + download_url = generate_romfile_download_url(request, file) + content_id = generate_content_id(file) + last_modified = format_pkgj_datetime(file.updated_at) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.zrif, - pkgj_item.content_id, - last_modified, - pkgj_item.original_name, - str(pkgj_item.file_size), - pkgj_item.sha_256, - pkgj_item.required_fw, - pkgj_item.app_version, - ] + pkgj_item = PkgjPSVGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(file.file_name_no_tags).strip(), + download_link=download_url, + zrif="", + content_id=content_id, + last_modified=file.updated_at, + original_name="", + file_size=file.file_size_bytes, + sha_256=file.sha1_hash or "", + required_fw="", + app_version="", ) - ) - return _text_response(txt_lines, "pkgj_psvita_games.txt") + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + pkgj_item.required_fw, + pkgj_item.app_version, + ] + ) + ) + + return text_response(txt_lines, "pkgj_psvita_games.txt") @protected_route( @@ -823,38 +853,43 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: ) for rom in roms: - download_url = generate_rom_download_url(request, rom) - last_modified = _format_pkgj_datetime(rom.updated_at) + for file in rom.files: + if not validate_pkgi_file(file, RomFileCategory.DLC): + continue - pkgj_item = PkgjPSVDlcsItemSchema( - title_id="", - region=rom.regions[0] if rom.regions else "", - name=(rom.name or rom.fs_name_no_tags).strip(), - download_link=download_url, - zrif="", - content_id="", - last_modified=rom.updated_at, - file_size=rom.fs_size_bytes, - sha_256=rom.sha1_hash or "", - ) + download_url = generate_romfile_download_url(request, file) + content_id = generate_content_id(file) + last_modified = format_pkgj_datetime(file.updated_at) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.zrif, - pkgj_item.content_id, - last_modified, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] + pkgj_item = PkgjPSVDlcsItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(file.file_name_no_tags).strip(), + download_link=download_url, + zrif="", + content_id=content_id, + last_modified=file.updated_at, + file_size=file.file_size_bytes, + sha_256=file.sha1_hash or "", ) - ) - return _text_response(txt_lines, "pkgj_psvita_dlc.txt") + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + last_modified, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return text_response(txt_lines, "pkgj_psvita_dlc.txt") @protected_route( @@ -874,35 +909,40 @@ def pkgj_psx_games_feed(request: Request) -> Response: ) for rom in roms: - download_url = generate_rom_download_url(request, rom) - last_modified = _format_pkgj_datetime(rom.updated_at) + for file in rom.files: + if not validate_pkgi_file(file, RomFileCategory.GAME): + continue - pkgj_item = PkgjPSXGamesItemSchema( - title_id="", - region=rom.regions[0] if rom.regions else "", - name=(rom.name or rom.fs_name_no_tags).strip(), - download_link=download_url, - content_id="", - last_modified=rom.updated_at, - original_name="", - file_size=rom.fs_size_bytes, - sha_256=rom.sha1_hash or "", - ) + download_url = generate_romfile_download_url(request, file) + content_id = generate_content_id(file) + last_modified = format_pkgj_datetime(file.updated_at) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - last_modified, - pkgj_item.original_name, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] + pkgj_item = PkgjPSXGamesItemSchema( + title_id="", + region=rom.regions[0] if rom.regions else "", + name=(file.file_name_no_tags).strip(), + download_link=download_url, + content_id=content_id, + last_modified=file.updated_at, + original_name="", + file_size=file.file_size_bytes, + sha_256=file.sha1_hash or "", ) - ) - return _text_response(txt_lines, "pkgj_psx_games.txt") + txt_lines.append( + "\t".join( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] + ) + ) + + return text_response(txt_lines, "pkgj_psx_games.txt") From 527f144c1f28b89cabbfce5654252ebaa3e7a21a Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Mon, 9 Feb 2026 23:52:45 -0500 Subject: [PATCH 5/8] trunk check fix --- backend/tests/endpoints/feeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/feeds.py b/backend/tests/endpoints/feeds.py index 0066ba071..d6eea5c4c 100644 --- a/backend/tests/endpoints/feeds.py +++ b/backend/tests/endpoints/feeds.py @@ -6,7 +6,7 @@ from main import app from handler.database import db_platform_handler, db_rom_handler from handler.metadata.base_handler import UniversalPlatformSlug as UPS from models.platform import Platform -from models.rom import Rom, RomFile, RomFileCategory, RomMetadata +from models.rom import Rom, RomFile, RomFileCategory @pytest.fixture From 7231414c67120808edb8d2271c09e40a4387b335 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 10 Feb 2026 08:56:30 -0500 Subject: [PATCH 6/8] use pkgj last_modified --- backend/endpoints/feeds.py | 20 ++++++++++---------- backend/endpoints/responses/feeds.py | 3 +-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 738e8d366..14c107088 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -681,7 +681,7 @@ def pkgj_psp_games_feed(request: Request) -> Response: name=(file.file_name_no_tags).strip(), download_link=download_url, content_id=content_id, - last_modified=file.updated_at, + last_modified=last_modified, rap=rap_hash, download_rap_file=rap_download_url, file_size=file.file_size_bytes, @@ -697,7 +697,7 @@ def pkgj_psp_games_feed(request: Request) -> Response: pkgj_item.name, pkgj_item.download_link, pkgj_item.content_id, - last_modified, + pkgj_item.last_modified, pkgj_item.rap, pkgj_item.download_rap_file, str(pkgj_item.file_size), @@ -743,7 +743,7 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: name=(file.file_name_no_tags).strip(), download_link=download_url, content_id=content_id, - last_modified=file.updated_at, + last_modified=last_modified, rap=rap_hash, download_rap_file=rap_download_url, file_size=file.file_size_bytes, @@ -758,7 +758,7 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: pkgj_item.name, pkgj_item.download_link, pkgj_item.content_id, - last_modified, + pkgj_item.last_modified, pkgj_item.rap, pkgj_item.download_rap_file, str(pkgj_item.file_size), @@ -804,7 +804,7 @@ def pkgj_psv_games_feed(request: Request) -> Response: download_link=download_url, zrif="", content_id=content_id, - last_modified=file.updated_at, + last_modified=last_modified, original_name="", file_size=file.file_size_bytes, sha_256=file.sha1_hash or "", @@ -821,7 +821,7 @@ def pkgj_psv_games_feed(request: Request) -> Response: pkgj_item.download_link, pkgj_item.zrif, pkgj_item.content_id, - last_modified, + pkgj_item.last_modified, pkgj_item.original_name, str(pkgj_item.file_size), pkgj_item.sha_256, @@ -868,7 +868,7 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: download_link=download_url, zrif="", content_id=content_id, - last_modified=file.updated_at, + last_modified=last_modified, file_size=file.file_size_bytes, sha_256=file.sha1_hash or "", ) @@ -882,7 +882,7 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: pkgj_item.download_link, pkgj_item.zrif, pkgj_item.content_id, - last_modified, + pkgj_item.last_modified, str(pkgj_item.file_size), pkgj_item.sha_256, ] @@ -923,7 +923,7 @@ def pkgj_psx_games_feed(request: Request) -> Response: name=(file.file_name_no_tags).strip(), download_link=download_url, content_id=content_id, - last_modified=file.updated_at, + last_modified=last_modified, original_name="", file_size=file.file_size_bytes, sha_256=file.sha1_hash or "", @@ -937,7 +937,7 @@ def pkgj_psx_games_feed(request: Request) -> Response: pkgj_item.name, pkgj_item.download_link, pkgj_item.content_id, - last_modified, + pkgj_item.last_modified, pkgj_item.original_name, str(pkgj_item.file_size), pkgj_item.sha_256, diff --git a/backend/endpoints/responses/feeds.py b/backend/endpoints/responses/feeds.py index b9240be3a..ff01a8a65 100644 --- a/backend/endpoints/responses/feeds.py +++ b/backend/endpoints/responses/feeds.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Annotated, Any, Final, NotRequired, TypedDict from pydantic import BaseModel, BeforeValidator, Field, field_validator @@ -255,7 +254,7 @@ class PkgjBaseItemSchema(BaseModel): name: str download_link: str content_id: str - last_modified: datetime + last_modified: str file_size: int sha_256: str From dc857d964925f316ad418dae97846b946f670aa3 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 10 Feb 2026 09:44:59 -0500 Subject: [PATCH 7/8] use csv writer to build feed response --- backend/endpoints/feeds.py | 274 +++++++++++++++++++------------------ 1 file changed, 142 insertions(+), 132 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 14c107088..f1fc92635 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -1,3 +1,5 @@ +import csv +import io from collections.abc import Sequence from datetime import datetime from typing import Annotated @@ -352,20 +354,21 @@ def pkgi_ps3_feed( ) # Format: contentid,type,name,description,rap,url,size,checksum - txt_lines.append( - ",".join( - [ - pkgi_item.contentid, - str(pkgi_item.type), - f'"{pkgi_item.name}"', - pkgi_item.description, - pkgi_item.rap, - f'"{pkgi_item.url}"', - str(pkgi_item.size), - pkgi_item.checksum, - ] - ) + output = io.StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgi_item.contentid, + str(pkgi_item.type), + pkgi_item.name, + pkgi_item.description, + pkgi_item.rap, + pkgi_item.url, + str(pkgi_item.size), + pkgi_item.checksum, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @@ -425,20 +428,21 @@ def pkgi_psvita_feed( ) # Format: contentid,flags,name,name2,zrif,url,size,checksum - txt_lines.append( - ",".join( - [ - pkgi_item.contentid, - str(pkgi_item.flags), - f'"{pkgi_item.name}"', - pkgi_item.name2, - pkgi_item.zrif, - f'"{pkgi_item.url}"', - str(pkgi_item.size), - pkgi_item.checksum, - ] - ) + output = io.StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgi_item.contentid, + str(pkgi_item.flags), + pkgi_item.name, + pkgi_item.name2, + pkgi_item.zrif, + pkgi_item.url, + str(pkgi_item.size), + pkgi_item.checksum, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @@ -501,20 +505,21 @@ def pkgi_psp_feed( ) # Format: contentid,type,name,description,rap,url,size,checksum - txt_lines.append( - ",".join( - [ - pkgi_item.contentid, - str(pkgi_item.type), - f'"{pkgi_item.name}"', - pkgi_item.description, - pkgi_item.rap, - f'"{pkgi_item.url}"', - str(pkgi_item.size), - pkgi_item.checksum, - ] - ) + output = io.StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgi_item.contentid, + str(pkgi_item.type), + pkgi_item.name, + pkgi_item.description, + pkgi_item.rap, + pkgi_item.url, + str(pkgi_item.size), + pkgi_item.checksum, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, f"pkgi_{content_type_enum.value}.txt") @@ -599,9 +604,8 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: txt_lines = [] txt_lines.append("1") # Database version - txt_lines.append( - "\t" - ) # Delimiter (cannot use csv (coma) as kekatsu does not support " (double quotes) as a text delimiter) + # Delimiter (cannot use csv (coma) as kekatsu does not support " (double quotes) as a text delimiter) + txt_lines.append("\t") for rom in roms: download_url = generate_rom_download_url(request, rom) @@ -627,21 +631,22 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: ) # Format: title platform region version author download_url filename size box_art_url - txt_lines.append( - "\t".join( - [ - kekatsu_item.title, - kekatsu_item.platform, - kekatsu_item.region, - kekatsu_item.version, - kekatsu_item.author, - kekatsu_item.download_url, - kekatsu_item.filename, - str(kekatsu_item.size), - kekatsu_item.box_art_url, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + kekatsu_item.title, + kekatsu_item.platform, + kekatsu_item.region, + kekatsu_item.version, + kekatsu_item.author, + kekatsu_item.download_url, + kekatsu_item.filename, + str(kekatsu_item.size), + kekatsu_item.box_art_url, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, f"kekatsu_{platform_slug}.txt") @@ -688,23 +693,24 @@ def pkgj_psp_games_feed(request: Request) -> Response: sha_256=file.sha1_hash or "", ) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.type, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - pkgj_item.last_modified, - pkgj_item.rap, - pkgj_item.download_rap_file, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.type, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + pkgj_item.last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, "pkgj_psp_games.txt") @@ -750,22 +756,23 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: sha_256=file.sha1_hash or "", ) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - pkgj_item.last_modified, - pkgj_item.rap, - pkgj_item.download_rap_file, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + pkgj_item.last_modified, + pkgj_item.rap, + pkgj_item.download_rap_file, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, "pkgj_psp_dlc.txt") @@ -812,24 +819,25 @@ def pkgj_psv_games_feed(request: Request) -> Response: app_version="", ) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.zrif, - pkgj_item.content_id, - pkgj_item.last_modified, - pkgj_item.original_name, - str(pkgj_item.file_size), - pkgj_item.sha_256, - pkgj_item.required_fw, - pkgj_item.app_version, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + pkgj_item.last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + pkgj_item.required_fw, + pkgj_item.app_version, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, "pkgj_psvita_games.txt") @@ -873,21 +881,22 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response: sha_256=file.sha1_hash or "", ) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.zrif, - pkgj_item.content_id, - pkgj_item.last_modified, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.zrif, + pkgj_item.content_id, + pkgj_item.last_modified, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, "pkgj_psvita_dlc.txt") @@ -929,20 +938,21 @@ def pkgj_psx_games_feed(request: Request) -> Response: sha_256=file.sha1_hash or "", ) - txt_lines.append( - "\t".join( - [ - pkgj_item.title_id, - pkgj_item.region, - pkgj_item.name, - pkgj_item.download_link, - pkgj_item.content_id, - pkgj_item.last_modified, - pkgj_item.original_name, - str(pkgj_item.file_size), - pkgj_item.sha_256, - ] - ) + output = io.StringIO() + writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + [ + pkgj_item.title_id, + pkgj_item.region, + pkgj_item.name, + pkgj_item.download_link, + pkgj_item.content_id, + pkgj_item.last_modified, + pkgj_item.original_name, + str(pkgj_item.file_size), + pkgj_item.sha_256, + ] ) + txt_lines.append(output.getvalue().strip()) return text_response(txt_lines, "pkgj_psx_games.txt") From acda4bb6ce48c728bbb8a058bee2f036a49e64e5 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 10 Feb 2026 09:59:29 -0500 Subject: [PATCH 8/8] move get_rap_data out of loop --- backend/endpoints/feeds.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index f1fc92635..a09002760 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -333,13 +333,13 @@ def pkgi_ps3_feed( txt_lines = [] for rom in roms: + rap_hash, _ = get_rap_data(request, rom) for file in rom.files: if not validate_pkgi_file(file, content_type_enum): continue content_id = generate_content_id(file) download_url = generate_romfile_download_url(request, file) - rap_hash, _ = get_rap_data(request, rom) # Validate the item schema pkgi_item = PKGiFeedPS3ItemSchema( @@ -484,13 +484,13 @@ def pkgi_psp_feed( txt_lines = [] for rom in roms: + rap_hash, _ = get_rap_data(request, rom) for file in rom.files: if not validate_pkgi_file(file, content_type_enum): continue content_id = generate_content_id(file) download_url = generate_romfile_download_url(request, file) - rap_hash, _ = get_rap_data(request, rom) # Validate the item schema pkgi_item = PKGiFeedPSPItemSchema( @@ -670,6 +670,7 @@ def pkgj_psp_games_feed(request: Request) -> Response: ) for rom in roms: + rap_hash, rap_download_url = get_rap_data(request, rom) for file in rom.files: if not validate_pkgi_file(file, RomFileCategory.GAME): continue @@ -677,7 +678,6 @@ def pkgj_psp_games_feed(request: Request) -> Response: download_url = generate_romfile_download_url(request, file) content_id = generate_content_id(file) last_modified = format_pkgj_datetime(file.updated_at) - rap_hash, rap_download_url = get_rap_data(request, rom) pkgj_item = PkgjPSPGamesItemSchema( title_id="", @@ -734,6 +734,7 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: ) for rom in roms: + rap_hash, rap_download_url = get_rap_data(request, rom) for file in rom.files: if not validate_pkgi_file(file, RomFileCategory.DLC): continue @@ -741,7 +742,6 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response: download_url = generate_romfile_download_url(request, file) content_id = generate_content_id(file) last_modified = format_pkgj_datetime(file.updated_at) - rap_hash, rap_download_url = get_rap_data(request, rom) pkgj_item = PkgjPSPDlcsItemSchema( title_id="",