@@ -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+
75109class 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 ,
0 commit comments