mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
ff081d7dc8
commit
7e906ad78e
3 changed files with 402 additions and 25 deletions
|
|
@ -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 é 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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue