Skip to content

Commit 579e125

Browse files
committed
fix(backend): discard ScreenScraper chroma-key green placeholder art
ScreenScraper serves a solid chroma-key green (#00FF00) square when a requested image (box-2D front/back/side) doesn't exist. RomM stored it as real artwork, so the v2 3D box painted a bright green face and the flat cover showed green in the gallery. Detect the placeholder at download time and drop it: covers fall back to the procedural placeholder, and a discarded box-2D face falls back to the dark cover placeholder the box already paints (rather than bright green). - Add _is_chroma_key_placeholder + _discard_if_chroma_key in the resources handler; wire into _store_cover and store_media_file. - store_media_file also cleans up pre-existing green files on rescan. Generated-By: PostHog Code Task-Id: 5534668f-a2b6-4575-b6c1-73dcd965062c
1 parent bc2b891 commit 579e125

2 files changed

Lines changed: 198 additions & 36 deletions

File tree

backend/handler/filesystem/resources_handler.py

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,40 @@ def _check_content_type(
7272
return True
7373

7474

75+
# Some providers (notably ScreenScraper) serve a solid chroma-key green square
76+
# as a stand-in when a requested image doesn't exist. Persisting it would paint
77+
# a bright green cover or 3D-box face, so we detect and drop it instead.
78+
_CHROMA_KEY_GREEN = (0, 255, 0)
79+
_CHROMA_KEY_TOLERANCE = 24
80+
_CHROMA_KEY_COVERAGE = 0.9
81+
82+
83+
def _is_chroma_key_placeholder(image_path: Path) -> bool:
84+
"""True if the image is (almost) entirely a chroma-key green fill."""
85+
try:
86+
with Image.open(image_path) as img:
87+
sample = img.convert("RGB")
88+
sample.thumbnail((32, 32)) # cheap: sample a downscaled copy
89+
raw = sample.tobytes() # flat RGB triples
90+
except (UnidentifiedImageError, OSError, ValueError):
91+
return False
92+
93+
total = len(raw) // 3
94+
if total == 0:
95+
return False
96+
97+
r0, g0, b0 = _CHROMA_KEY_GREEN
98+
green = 0
99+
for i in range(0, total * 3, 3):
100+
if (
101+
abs(raw[i] - r0) <= _CHROMA_KEY_TOLERANCE
102+
and abs(raw[i + 1] - g0) <= _CHROMA_KEY_TOLERANCE
103+
and abs(raw[i + 2] - b0) <= _CHROMA_KEY_TOLERANCE
104+
):
105+
green += 1
106+
return green / total >= _CHROMA_KEY_COVERAGE
107+
108+
75109
class FSResourcesHandler(FSHandler):
76110
def __init__(self) -> None:
77111
super().__init__(base_path=RESOURCES_BASE_PATH)
@@ -110,6 +144,22 @@ def resize_cover_to_small(self, cover: ImageFile.ImageFile, save_path: str) -> N
110144

111145
small_img.save(save_path)
112146

147+
async def _discard_if_chroma_key(self, relative_path: str) -> bool:
148+
"""Remove a just-downloaded image if it's a chroma-key placeholder.
149+
150+
Returns True when the file was discarded, so callers can treat the
151+
artwork as missing (falling back to the dark placeholder).
152+
"""
153+
if not await self.file_exists(relative_path):
154+
return False
155+
156+
if not _is_chroma_key_placeholder(self.validate_path(relative_path)):
157+
return False
158+
159+
log.debug(f"Discarding chroma-key placeholder image {relative_path}")
160+
await self.remove_file(relative_path)
161+
return True
162+
113163
async def _store_cover(
114164
self, entity: Rom | Collection, url_cover: str, size: CoverSize
115165
) -> None:
@@ -136,6 +186,9 @@ async def _store_cover(
136186
# the user's source image if the destination were a hardlink.
137187
await self.copy_file(resolved, dest_path, allow_link=False)
138188

189+
if await self._discard_if_chroma_key(dest_path):
190+
return None
191+
139192
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
140193
self.image_converter.convert_to_webp(
141194
self.validate_path(f"{cover_file}/{size.value}.png"),
@@ -177,6 +230,11 @@ async def _store_cover(
177230
async for chunk in response.aiter_raw():
178231
await f.write(chunk)
179232

233+
if await self._discard_if_chroma_key(
234+
f"{cover_file}/{size.value}.png"
235+
):
236+
return None
237+
180238
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
181239
self.image_converter.convert_to_webp(
182240
self.validate_path(f"{cover_file}/{size.value}.png"),
@@ -551,48 +609,52 @@ def get_media_resources_path(
551609
return os.path.join("roms", str(platform_id), str(rom_id), media_type.value)
552610

553611
async def store_media_file(self, url_media: str, dest_path: str) -> None:
554-
httpx_client = ctx_httpx_client.get()
555612
directory, filename = os.path.split(dest_path)
556613

557614
if await self.file_exists(dest_path):
558615
log.debug(f"Media file {dest_path} already exists, skipping download")
559-
return
560-
561-
# Ensure destination directory exists
562-
await self.make_directory(directory)
563-
564-
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
565-
if url_media.startswith(LOCAL_FILE_SCHEMES):
566-
try:
567-
resolved = _resolve_local_file_uri(url_media)
568-
if resolved is not None and await AnyioPath(resolved).exists():
569-
await self.copy_file(resolved, dest_path, allow_link=True)
570-
except Exception as exc:
571-
log.error(f"Unable to copy media file {url_media}: {str(exc)}")
572-
return None
573616
else:
574-
# Handle HTTP URLs
575-
httpx_client = ctx_httpx_client.get()
576-
try:
577-
async with httpx_client.stream(
578-
"GET", url_media, timeout=120
579-
) as response:
580-
if response.status_code == status.HTTP_200_OK:
581-
if not _check_content_type(
582-
response,
583-
("image/", "video/", "application/pdf"),
584-
"media",
585-
):
586-
return None
617+
# Ensure destination directory exists
618+
await self.make_directory(directory)
619+
620+
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
621+
if url_media.startswith(LOCAL_FILE_SCHEMES):
622+
try:
623+
resolved = _resolve_local_file_uri(url_media)
624+
if resolved is not None and await AnyioPath(resolved).exists():
625+
await self.copy_file(resolved, dest_path, allow_link=True)
626+
except Exception as exc:
627+
log.error(f"Unable to copy media file {url_media}: {str(exc)}")
628+
return None
629+
else:
630+
# Handle HTTP URLs
631+
httpx_client = ctx_httpx_client.get()
632+
try:
633+
async with httpx_client.stream(
634+
"GET", url_media, timeout=120
635+
) as response:
636+
if response.status_code == status.HTTP_200_OK:
637+
if not _check_content_type(
638+
response,
639+
("image/", "video/", "application/pdf"),
640+
"media",
641+
):
642+
return None
643+
644+
async with await self.write_file_streamed(
645+
path=directory, filename=filename
646+
) as f:
647+
async for chunk in response.aiter_raw():
648+
await f.write(chunk)
649+
except httpx.TransportError as exc:
650+
log.error(f"Unable to fetch media file at {url_media}: {str(exc)}")
651+
return None
587652

588-
async with await self.write_file_streamed(
589-
path=directory, filename=filename
590-
) as f:
591-
async for chunk in response.aiter_raw():
592-
await f.write(chunk)
593-
except httpx.TransportError as exc:
594-
log.error(f"Unable to fetch media file at {url_media}: {str(exc)}")
595-
return None
653+
# Drop ScreenScraper's green "missing art" placeholder so a box face
654+
# (box-2D-back / box-2D-side) falls back to the dark placeholder rather
655+
# than rendering bright green. Runs for pre-existing files too, cleaning
656+
# them up on rescan.
657+
await self._discard_if_chroma_key(dest_path)
596658

597659
async def remove_media_resources_path(
598660
self,

backend/tests/handler/filesystem/test_resources_handler.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
import httpx
66
import pytest
7+
from PIL import Image
78

89
from config import RESOURCES_BASE_PATH
910
from handler.filesystem.base_handler import CoverSize
1011
from handler.filesystem.resources_handler import (
1112
FSResourcesHandler,
1213
_check_content_type,
1314
_content_type_essence,
15+
_is_chroma_key_placeholder,
1416
)
1517
from models.collection import Collection
1618
from models.rom import Rom
@@ -662,3 +664,101 @@ def test_existing_resources_structure(self, handler: FSResourcesHandler):
662664
assert isinstance(ra_badges, str)
663665
assert "retroachievements" in ra_base
664666
assert "badges" in ra_badges
667+
668+
669+
class TestChromaKeyDetection:
670+
"""Tests for ScreenScraper chroma-key green placeholder handling."""
671+
672+
@pytest.fixture
673+
def handler(self):
674+
return FSResourcesHandler()
675+
676+
def _write_image(self, path: Path, color: tuple[int, int, int]) -> None:
677+
path.parent.mkdir(parents=True, exist_ok=True)
678+
Image.new("RGB", (64, 64), color).save(path)
679+
680+
def test_detects_solid_chroma_key_green(self, tmp_path):
681+
image = tmp_path / "green.png"
682+
self._write_image(image, (0, 255, 0))
683+
assert _is_chroma_key_placeholder(image) is True
684+
685+
def test_detects_near_chroma_key_green(self, tmp_path):
686+
# Within tolerance of pure #00FF00.
687+
image = tmp_path / "near_green.png"
688+
self._write_image(image, (10, 250, 8))
689+
assert _is_chroma_key_placeholder(image) is True
690+
691+
def test_ignores_normal_artwork(self, tmp_path):
692+
image = tmp_path / "cover.png"
693+
self._write_image(image, (85, 62, 152)) # the placeholder purple
694+
assert _is_chroma_key_placeholder(image) is False
695+
696+
def test_ignores_forest_green_artwork(self, tmp_path):
697+
# A dark/natural green cover must not be mistaken for the chroma key.
698+
image = tmp_path / "forest.png"
699+
self._write_image(image, (34, 139, 34))
700+
assert _is_chroma_key_placeholder(image) is False
701+
702+
def test_ignores_non_image_file(self, tmp_path):
703+
not_an_image = tmp_path / "data.bin"
704+
not_an_image.write_bytes(b"not an image")
705+
assert _is_chroma_key_placeholder(not_an_image) is False
706+
707+
@pytest.mark.asyncio
708+
async def test_discard_removes_chroma_key_file(
709+
self, handler: FSResourcesHandler, tmp_path
710+
):
711+
handler.base_path = tmp_path
712+
rel = "roms/1/1/box2d_back/box2d_back.png"
713+
self._write_image(tmp_path / rel, (0, 255, 0))
714+
715+
discarded = await handler._discard_if_chroma_key(rel)
716+
717+
assert discarded is True
718+
assert not (tmp_path / rel).exists()
719+
720+
@pytest.mark.asyncio
721+
async def test_discard_keeps_real_artwork(
722+
self, handler: FSResourcesHandler, tmp_path
723+
):
724+
handler.base_path = tmp_path
725+
rel = "roms/1/1/box2d_back/box2d_back.png"
726+
self._write_image(tmp_path / rel, (85, 62, 152))
727+
728+
discarded = await handler._discard_if_chroma_key(rel)
729+
730+
assert discarded is False
731+
assert (tmp_path / rel).exists()
732+
733+
@pytest.mark.asyncio
734+
async def test_discard_missing_file_is_noop(
735+
self, handler: FSResourcesHandler, tmp_path
736+
):
737+
handler.base_path = tmp_path
738+
assert await handler._discard_if_chroma_key("roms/1/1/nope.png") is False
739+
740+
@pytest.mark.asyncio
741+
async def test_store_media_file_discards_existing_chroma_key(
742+
self, handler: FSResourcesHandler, tmp_path
743+
):
744+
# A green placeholder already on disk is dropped on rescan, so the box
745+
# face falls back to the dark placeholder instead of rendering green.
746+
handler.base_path = tmp_path
747+
rel = "roms/1/1/box2d_back/box2d_back.png"
748+
self._write_image(tmp_path / rel, (0, 255, 0))
749+
750+
await handler.store_media_file("http://example.com/x.png", rel)
751+
752+
assert not (tmp_path / rel).exists()
753+
754+
@pytest.mark.asyncio
755+
async def test_store_media_file_keeps_existing_real_art(
756+
self, handler: FSResourcesHandler, tmp_path
757+
):
758+
handler.base_path = tmp_path
759+
rel = "roms/1/1/box2d_back/box2d_back.png"
760+
self._write_image(tmp_path / rel, (85, 62, 152))
761+
762+
await handler.store_media_file("http://example.com/x.png", rel)
763+
764+
assert (tmp_path / rel).exists()

0 commit comments

Comments
 (0)