feat(epg): enhance poster style selection and fetching logic

Introduce a new poster style selection feature in the EPG settings, allowing users to choose their preferred artwork style. Update the logic for fetching and displaying program artwork from Schedules Direct, ensuring that the selected style is honored with appropriate fallbacks. Set default poster art to 'primary'. Add comprehensive tests to validate the poster selection functionality across various scenarios.
This commit is contained in:
SergeantPanda 2026-06-05 10:14:07 -05:00
parent ff081d7dc8
commit 7e906ad78e
3 changed files with 402 additions and 25 deletions

View file

@ -35,6 +35,161 @@ SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
SD_DAYS_TO_FETCH = 20
SD_PROGRAM_BATCH_SIZE = 5000
SD_POSTER_CATEGORIES = (
'Iconic', 'Banner-L1', 'Banner-L2', 'Banner-L3', 'Banner',
'Staple', 'Poster Art', 'Box Art',
)
SD_POSTER_STYLE_CONFIG = {
'portrait_iconic': {
'aspect_groups': (('2x3', '3x4'),),
'categories': ('Iconic',),
},
'portrait_banner': {
'aspect_groups': (('2x3', '3x4'),),
'categories': ('Banner-L1', 'Banner-L2', 'Banner-L3', 'Banner'),
},
'landscape_iconic': {
'aspect_groups': (('16x9', '4x3'),),
'categories': ('Iconic',),
},
'landscape_banner': {
'aspect_groups': (('16x9', '4x3'),),
'categories': ('Banner-L1', 'Banner-L2', 'Banner-L3', 'Banner'),
},
'square_iconic': {
'aspect_groups': (('1x1',),),
'categories': ('Iconic',),
},
}
def _sd_image_width(img):
try:
return int(img.get('width') or 0)
except (TypeError, ValueError):
return 0
def _sd_is_primary(img):
val = img.get('primary')
if val is True:
return True
if isinstance(val, str):
return val.lower() in ('true', '1', 'yes')
return False
def _sd_matching_images(images, *, categories=None, aspects=None, min_width=0, primary_only=False):
matches = []
for img in images:
if not isinstance(img, dict):
continue
if primary_only and not _sd_is_primary(img):
continue
if categories is not None and img.get('category') not in categories:
continue
if aspects is not None and img.get('aspect') not in aspects:
continue
if _sd_image_width(img) < min_width:
continue
if img.get('uri'):
matches.append(img)
return matches
def _sd_best_image(matches):
if not matches:
return None
best = max(matches, key=lambda img: (_sd_is_primary(img), _sd_image_width(img)))
return best.get('uri')
def _sd_find_image(images, *, categories=None, aspects=None, min_width=0, primary_only=False):
return _sd_best_image(_sd_matching_images(
images,
categories=categories,
aspects=aspects,
min_width=min_width,
primary_only=primary_only,
))
SD_POSTER_STYLE_DEFAULT = 'sd_recommended'
SD_POSTER_PORTRAIT_FALLBACK = 'portrait_iconic'
def _sd_pick_recommended_poster_url(images):
"""Use Gracenote's primary flag, then fall back to portrait iconic."""
min_widths = (240, 135, 120, 0)
for min_w in min_widths:
uri = _sd_find_image(
images,
categories=SD_POSTER_CATEGORIES,
aspects=None,
min_width=min_w,
primary_only=True,
)
if uri:
return uri
for min_w in min_widths:
uri = _sd_find_image(
images,
categories=None,
aspects=None,
min_width=min_w,
primary_only=True,
)
if uri:
return uri
return _sd_pick_poster_url(images, SD_POSTER_PORTRAIT_FALLBACK)
def _sd_pick_poster_url(images, poster_style=SD_POSTER_STYLE_DEFAULT):
"""Pick the best SD poster URI for the user's style preference, with fallbacks."""
if poster_style == 'sd_recommended':
return _sd_pick_recommended_poster_url(images)
config = SD_POSTER_STYLE_CONFIG.get(poster_style)
if not config:
return _sd_pick_recommended_poster_url(images)
min_widths = (240, 135, 120, 0)
for min_w in min_widths:
for cat in config['categories']:
for aspects in config['aspect_groups']:
uri = _sd_find_image(images, categories=(cat,), aspects=aspects, min_width=min_w)
if uri:
return uri
for min_w in min_widths:
for aspects in config['aspect_groups']:
uri = _sd_find_image(images, categories=SD_POSTER_CATEGORIES, aspects=aspects, min_width=min_w)
if uri:
return uri
for aspects in config['aspect_groups']:
uri = _sd_find_image(images, categories=None, aspects=aspects, min_width=0)
if uri:
return uri
# Fallback: SD primary among poster categories (any aspect)
for min_w in min_widths:
uri = _sd_find_image(
images,
categories=SD_POSTER_CATEGORIES,
aspects=None,
min_width=min_w,
primary_only=True,
)
if uri:
return uri
if poster_style != SD_POSTER_PORTRAIT_FALLBACK:
return _sd_pick_poster_url(images, SD_POSTER_PORTRAIT_FALLBACK)
return None
# DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named
# entities so lxml/libxml2 can resolve references like &eacute; correctly
# instead of silently dropping them in recovery mode.
@ -2092,18 +2247,24 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
from apps.epg.models import SDProgramMD5
fetch_posters = (source.custom_properties or {}).get('fetch_posters', False)
poster_program_ids = set(program_metadata.keys()) if program_metadata else set()
if fetch_posters and not poster_program_ids:
poster_style = (source.custom_properties or {}).get('poster_style', SD_POSTER_STYLE_DEFAULT)
poster_program_ids = set()
if fetch_posters:
needs_poster_q = (
Q(custom_properties__isnull=True)
| ~Q(custom_properties__has_key='sd_icon')
| ~Q(custom_properties__sd_poster_style=poster_style)
)
poster_program_ids = set(
ProgramData.objects.filter(
epg_id__in=mapped_epg_ids,
program_id__isnull=False,
).exclude(custom_properties__has_key='sd_icon')
.values_list('program_id', flat=True)
).filter(needs_poster_q).values_list('program_id', flat=True)
)
if poster_program_ids:
logger.info(
f"Poster backfill: {len(poster_program_ids)} programs missing sd_icon."
f"Poster fetch: {len(poster_program_ids)} programs need artwork "
f"(missing, style change, or first fetch; style={poster_style})."
)
if fetch_posters and poster_program_ids:
@ -2152,23 +2313,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
if not images:
continue
poster_url = None
for min_width in [240, 135, 120]:
for pref_cat in ['Banner-L1', 'Iconic']:
match = next((img for img in images
if img.get('aspect') in ('2x3', '3x4')
and img.get('category') == pref_cat
and (img.get('width', 0) or 0) >= min_width), None)
if match:
poster_url = match.get('uri')
break
if poster_url:
break
if not poster_url:
portrait = next((img for img in images
if img.get('aspect') in ('2x3', '3x4')), None)
if portrait:
poster_url = portrait.get('uri')
poster_url = _sd_pick_poster_url(images, poster_style)
if poster_url:
if not poster_url.startswith('http'):
poster_url = f"{SD_BASE_URL}/image/{poster_url}"
@ -2183,6 +2328,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
programs_to_update = []
for prog in ProgramData.objects.filter(
epg_id__in=mapped_epg_ids,
program_id__in=poster_program_ids,
program_id__isnull=False,
).only('id', 'program_id', 'custom_properties'):
art_key = pid_to_artwork_key.get(prog.program_id)
@ -2190,6 +2336,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
if poster:
cp = prog.custom_properties or {}
cp['sd_icon'] = poster
cp['sd_poster_style'] = poster_style
prog.custom_properties = cp
programs_to_update.append(prog)
if programs_to_update:

View file

@ -261,3 +261,174 @@ class SDSourceSignalTests(TestCase):
)
mock_parse.delay.assert_not_called()
# ---------------------------------------------------------------------------
# Poster selection tests
# ---------------------------------------------------------------------------
class SDPosterSelectionTests(TestCase):
"""_sd_pick_poster_url must honour style preference with sensible fallbacks."""
def _images(self):
return [
{
'uri': 'assets/iconic_portrait.jpg',
'width': '960',
'aspect': '2x3',
'category': 'Iconic',
},
{
'uri': 'assets/banner_portrait.jpg',
'width': '360',
'aspect': '2x3',
'category': 'Banner-L1',
},
{
'uri': 'assets/iconic_landscape.jpg',
'width': '1920',
'aspect': '16x9',
'category': 'Iconic',
},
{
'uri': 'assets/banner_landscape.jpg',
'width': '1280',
'aspect': '16x9',
'category': 'Banner-L1',
},
]
def test_portrait_iconic_prefers_iconic_over_banner(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'portrait_iconic'),
'assets/iconic_portrait.jpg',
)
def test_portrait_banner_prefers_banner(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'portrait_banner'),
'assets/banner_portrait.jpg',
)
def test_landscape_iconic_prefers_landscape_iconic(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'landscape_iconic'),
'assets/iconic_landscape.jpg',
)
def test_landscape_falls_back_to_portrait_when_unavailable(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [img for img in self._images() if img['aspect'] in ('2x3', '3x4')]
self.assertEqual(
_sd_pick_poster_url(images, 'landscape_iconic'),
'assets/iconic_portrait.jpg',
)
def test_unknown_style_defaults_to_sd_recommended(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'not_a_real_style'),
'assets/iconic_portrait.jpg',
)
def test_prefers_primary_when_category_and_aspect_match(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/banner_small.jpg',
'width': '120',
'aspect': '2x3',
'category': 'Banner-L1',
},
{
'uri': 'assets/banner_primary.jpg',
'width': '360',
'aspect': '2x3',
'category': 'Banner-L1',
'primary': 'true',
},
]
self.assertEqual(
_sd_pick_poster_url(images, 'portrait_banner'),
'assets/banner_primary.jpg',
)
def test_sd_recommended_uses_primary_poster_category(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/cast_primary.jpg',
'width': '500',
'aspect': '3x4',
'category': 'Cast in Character',
'primary': 'true',
},
{
'uri': 'assets/iconic_primary.jpg',
'width': '300',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
self.assertEqual(
_sd_pick_poster_url(images, 'sd_recommended'),
'assets/iconic_primary.jpg',
)
def test_sd_recommended_falls_back_to_portrait_iconic(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'sd_recommended'),
'assets/iconic_portrait.jpg',
)
def test_default_style_is_sd_recommended(self):
from apps.epg.tasks import _sd_pick_poster_url, SD_POSTER_STYLE_DEFAULT
self.assertEqual(SD_POSTER_STYLE_DEFAULT, 'sd_recommended')
images = [
{
'uri': 'assets/primary.jpg',
'width': '960',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
self.assertEqual(_sd_pick_poster_url(images), 'assets/primary.jpg')
def test_style_fallback_uses_primary_before_cross_orientation(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/iconic_portrait.jpg',
'width': '960',
'aspect': '2x3',
'category': 'Iconic',
},
{
'uri': 'assets/landscape_primary.jpg',
'width': '1920',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
# square_iconic has no 1x1 images; should pick SD primary before portrait iconic fallback
self.assertEqual(
_sd_pick_poster_url(images, 'square_iconic'),
'assets/landscape_primary.jpg',
)

View file

@ -75,6 +75,33 @@ const SD_LOGO_STYLES = [
},
];
const SD_POSTER_STYLES = [
{
value: 'sd_recommended',
label: 'SD Recommended (primary artwork)',
},
{
value: 'portrait_banner',
label: 'Portrait, Banner with title (2×3 / 3×4)',
},
{
value: 'portrait_iconic',
label: 'Portrait, Iconic (2×3 / 3×4)',
},
{
value: 'landscape_iconic',
label: 'Landscape, Iconic (16×9 / 4×3)',
},
{
value: 'landscape_banner',
label: 'Landscape, Banner with title (16×9 / 4×3)',
},
{
value: 'square_iconic',
label: 'Square, Iconic (1×1)',
},
];
// SD Settings: Logo toggle + style selector + Poster toggle
const SDSettings = ({ sourceId, customProperties }) => {
const storeCustomProps = useEPGsStore((s) =>
@ -87,6 +114,9 @@ const SDSettings = ({ sourceId, customProperties }) => {
);
const [logoStyle, setLogoStyle] = useState(resolvedCp.logo_style || 'dark');
const [fetchPosters, setFetchPosters] = useState(!!resolvedCp.fetch_posters);
const [posterStyle, setPosterStyle] = useState(
resolvedCp.poster_style || 'sd_recommended'
);
const [saving, setSaving] = useState(false);
// Sync from store (preferred) or parent props when the form opens or settings save
@ -95,6 +125,7 @@ const SDSettings = ({ sourceId, customProperties }) => {
setAutoApplySDLogos(!!newCp.auto_apply_sd_logos);
setLogoStyle(newCp.logo_style || 'dark');
setFetchPosters(!!newCp.fetch_posters);
setPosterStyle(newCp.poster_style || 'sd_recommended');
}, [storeCustomProps, customProperties]);
const saveSetting = async (key, value) => {
@ -121,6 +152,12 @@ const SDSettings = ({ sourceId, customProperties }) => {
saveSetting('fetch_posters', checked);
};
const handlePosterStyleChange = (style) => {
if (!style) return;
setPosterStyle(style);
saveSetting('poster_style', style);
};
return (
<Box>
<Text size="sm" fw={500} mb="xs">
@ -195,7 +232,21 @@ const SDSettings = ({ sourceId, customProperties }) => {
onChange={(e) => handlePosterToggle(e.currentTarget.checked)}
disabled={saving}
size="sm"
mb={fetchPosters ? 'xs' : undefined}
/>
{fetchPosters && (
<Select
label="Poster Style"
description="SD Recommended uses Gracenote's primary flag, then portrait iconic. Other styles match your preference, then SD primary, then portrait iconic."
data={SD_POSTER_STYLES}
value={posterStyle}
onChange={handlePosterStyleChange}
disabled={saving}
size="sm"
allowDeselect={false}
/>
)}
</Box>
);
};
@ -618,11 +669,19 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
await updateEPG(values, epgObj);
onClose();
} else {
const result = await addEPG(values);
const payload = { ...values };
if (payload.source_type === 'schedules_direct') {
payload.custom_properties = {
logo_style: 'dark',
poster_style: 'sd_recommended',
};
}
const result = await addEPG(payload);
if (result?.id) {
setSavedEpgId(result.id);
// Load custom_properties for the new source
setSdCustomProps(result.custom_properties || {});
setSdCustomProps(
result.custom_properties || payload.custom_properties || {}
);
} else {
form.reset();
onClose();