From 28c211cd561b8699ab683f0eb031d77f655fd2ae Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 29 Oct 2025 17:10:35 -0500 Subject: [PATCH] Enhancement: Add {endtime} as an available output for custom dummy epg. Convert {time} to {starttime} Closes #590 --- ..._migrate_time_to_starttime_placeholders.py | 119 ++++++++++++++++ apps/output/views.py | 43 +++++- frontend/src/components/forms/DummyEPG.jsx | 128 ++++++++++++++---- 3 files changed, 259 insertions(+), 31 deletions(-) create mode 100644 apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py diff --git a/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py b/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py new file mode 100644 index 00000000..8f53bb0a --- /dev/null +++ b/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py @@ -0,0 +1,119 @@ +# Generated migration to replace {time} placeholders with {starttime} + +import re +from django.db import migrations + + +def migrate_time_placeholders(apps, schema_editor): + """ + Replace {time} with {starttime} and {time24} with {starttime24} + in all dummy EPG source custom_properties templates. + """ + EPGSource = apps.get_model('epg', 'EPGSource') + + # Fields that contain templates with placeholders + template_fields = [ + 'title_template', + 'description_template', + 'upcoming_title_template', + 'upcoming_description_template', + 'ended_title_template', + 'ended_description_template', + 'channel_logo_url', + 'program_poster_url', + ] + + # Get all dummy EPG sources + dummy_sources = EPGSource.objects.filter(source_type='dummy') + + updated_count = 0 + for source in dummy_sources: + if not source.custom_properties: + continue + + modified = False + custom_props = source.custom_properties.copy() + + for field in template_fields: + if field in custom_props and custom_props[field]: + original_value = custom_props[field] + + # Replace {time24} first (before {time}) to avoid double replacement + # e.g., {time24} shouldn't become {starttime24} via {time} -> {starttime} + new_value = original_value + new_value = re.sub(r'\{time24\}', '{starttime24}', new_value) + new_value = re.sub(r'\{time\}', '{starttime}', new_value) + + if new_value != original_value: + custom_props[field] = new_value + modified = True + + if modified: + source.custom_properties = custom_props + source.save(update_fields=['custom_properties']) + updated_count += 1 + + if updated_count > 0: + print(f"Migration complete: Updated {updated_count} dummy EPG source(s) with new placeholder names.") + else: + print("No dummy EPG sources needed placeholder updates.") + + +def reverse_migration(apps, schema_editor): + """ + Reverse the migration by replacing {starttime} back to {time}. + """ + EPGSource = apps.get_model('epg', 'EPGSource') + + template_fields = [ + 'title_template', + 'description_template', + 'upcoming_title_template', + 'upcoming_description_template', + 'ended_title_template', + 'ended_description_template', + 'channel_logo_url', + 'program_poster_url', + ] + + dummy_sources = EPGSource.objects.filter(source_type='dummy') + + updated_count = 0 + for source in dummy_sources: + if not source.custom_properties: + continue + + modified = False + custom_props = source.custom_properties.copy() + + for field in template_fields: + if field in custom_props and custom_props[field]: + original_value = custom_props[field] + + # Reverse the replacements + new_value = original_value + new_value = re.sub(r'\{starttime24\}', '{time24}', new_value) + new_value = re.sub(r'\{starttime\}', '{time}', new_value) + + if new_value != original_value: + custom_props[field] = new_value + modified = True + + if modified: + source.custom_properties = custom_props + source.save(update_fields=['custom_properties']) + updated_count += 1 + + if updated_count > 0: + print(f"Reverse migration complete: Reverted {updated_count} dummy EPG source(s) to old placeholder names.") + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0019_alter_programdata_sub_title'), + ] + + operations = [ + migrations.RunPython(migrate_time_placeholders, reverse_migration), + ] diff --git a/apps/output/views.py b/apps/output/views.py index f4b79058..1fc98d8e 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -571,13 +571,13 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust minute = temp_date_output.minute logger.debug(f"Converted display time from {source_tz} to {output_tz}: {hour_24}:{minute:02d}") - # Format 24-hour time string - only include minutes if non-zero + # Format 24-hour start time string - only include minutes if non-zero if minute > 0: - all_groups['time24'] = f"{hour_24}:{minute:02d}" + all_groups['starttime24'] = f"{hour_24}:{minute:02d}" else: - all_groups['time24'] = f"{hour_24:02d}:00" + all_groups['starttime24'] = f"{hour_24:02d}:00" - # Convert 24-hour to 12-hour format for {time} placeholder + # Convert 24-hour to 12-hour format for {starttime} placeholder # Note: hour_24 is ALWAYS in 24-hour format at this point (converted earlier if needed) ampm = 'AM' if hour_24 < 12 else 'PM' hour_12 = hour_24 @@ -586,11 +586,40 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust elif hour_24 > 12: hour_12 = hour_24 - 12 - # Format 12-hour time string - only include minutes if non-zero + # Format 12-hour start time string - only include minutes if non-zero if minute > 0: - all_groups['time'] = f"{hour_12}:{minute:02d} {ampm}" + all_groups['starttime'] = f"{hour_12}:{minute:02d} {ampm}" else: - all_groups['time'] = f"{hour_12} {ampm}" + all_groups['starttime'] = f"{hour_12} {ampm}" + + # Calculate end time based on program duration + # Create a datetime for calculations + temp_start = datetime.now(source_tz).replace(hour=hour_24, minute=minute, second=0, microsecond=0) + temp_end = temp_start + timedelta(minutes=program_duration) + + # Extract end time components (already in correct timezone if output_tz was applied above) + end_hour_24 = temp_end.hour + end_minute = temp_end.minute + + # Format 24-hour end time string - only include minutes if non-zero + if end_minute > 0: + all_groups['endtime24'] = f"{end_hour_24}:{end_minute:02d}" + else: + all_groups['endtime24'] = f"{end_hour_24:02d}:00" + + # Convert 24-hour to 12-hour format for {endtime} placeholder + end_ampm = 'AM' if end_hour_24 < 12 else 'PM' + end_hour_12 = end_hour_24 + if end_hour_24 == 0: + end_hour_12 = 12 + elif end_hour_24 > 12: + end_hour_12 = end_hour_24 - 12 + + # Format 12-hour end time string - only include minutes if non-zero + if end_minute > 0: + all_groups['endtime'] = f"{end_hour_12}:{end_minute:02d} {end_ampm}" + else: + all_groups['endtime'] = f"{end_hour_12} {end_ampm}" # Generate programs programs = [] diff --git a/frontend/src/components/forms/DummyEPG.jsx b/frontend/src/components/forms/DummyEPG.jsx index 9b5b3178..003cd9b3 100644 --- a/frontend/src/components/forms/DummyEPG.jsx +++ b/frontend/src/components/forms/DummyEPG.jsx @@ -106,6 +106,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { titleGroups: {}, timeGroups: {}, dateGroups: {}, + calculatedPlaceholders: {}, formattedTitle: '', formattedDescription: '', formattedUpcomingTitle: '', @@ -223,11 +224,11 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { hour24 = outputDate.hour(); const convertedMinute = outputDate.minute(); - // Format 24-hour time string with converted time + // Format 24-hour start time string with converted time if (convertedMinute > 0) { - allGroups.time24 = `${hour24.toString().padStart(2, '0')}:${convertedMinute.toString().padStart(2, '0')}`; + allGroups.starttime24 = `${hour24.toString().padStart(2, '0')}:${convertedMinute.toString().padStart(2, '0')}`; } else { - allGroups.time24 = `${hour24.toString().padStart(2, '0')}:00`; + allGroups.starttime24 = `${hour24.toString().padStart(2, '0')}:00`; } // Convert to 12-hour format with converted time @@ -239,19 +240,19 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { hour12 = hour24 - 12; } - // Format 12-hour time string with converted time + // Format 12-hour start time string with converted time if (convertedMinute > 0) { - allGroups.time = `${hour12}:${convertedMinute.toString().padStart(2, '0')} ${ampmDisplay}`; + allGroups.starttime = `${hour12}:${convertedMinute.toString().padStart(2, '0')} ${ampmDisplay}`; } else { - allGroups.time = `${hour12} ${ampmDisplay}`; + allGroups.starttime = `${hour12} ${ampmDisplay}`; } } else { // No timezone conversion - use original logic - // Format 24-hour time string + // Format 24-hour start time string if (minute > 0) { - allGroups.time24 = `${hour24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + allGroups.starttime24 = `${hour24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; } else { - allGroups.time24 = `${hour24.toString().padStart(2, '0')}:00`; + allGroups.starttime24 = `${hour24.toString().padStart(2, '0')}:00`; } // Convert to 12-hour format @@ -263,15 +264,57 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { hour12 = hour24 - 12; } - // Format 12-hour time string + // Format 12-hour start time string if (minute > 0) { - allGroups.time = `${hour12}:${minute.toString().padStart(2, '0')} ${ampmDisplay}`; + allGroups.starttime = `${hour12}:${minute.toString().padStart(2, '0')} ${ampmDisplay}`; } else { - allGroups.time = `${hour12} ${ampmDisplay}`; + allGroups.starttime = `${hour12} ${ampmDisplay}`; } } + + // Calculate end time based on program duration + const programDuration = + form.values.custom_properties?.program_duration || 180; + + // Calculate end time by adding duration to start time + const startMinutes = hour24 * 60 + minute; + const endMinutes = startMinutes + programDuration; + + let endHour24 = Math.floor(endMinutes / 60) % 24; // Wrap around 24 hours + const endMinute = endMinutes % 60; + + // Format 24-hour end time string + if (endMinute > 0) { + allGroups.endtime24 = `${endHour24.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}`; + } else { + allGroups.endtime24 = `${endHour24.toString().padStart(2, '0')}:00`; + } + + // Convert to 12-hour format for endtime + const endAmpmDisplay = endHour24 < 12 ? 'AM' : 'PM'; + let endHour12 = endHour24; + if (endHour24 === 0) { + endHour12 = 12; + } else if (endHour24 > 12) { + endHour12 = endHour24 - 12; + } + + // Format 12-hour end time string + if (endMinute > 0) { + allGroups.endtime = `${endHour12}:${endMinute.toString().padStart(2, '0')} ${endAmpmDisplay}`; + } else { + allGroups.endtime = `${endHour12} ${endAmpmDisplay}`; + } + + // Store calculated placeholders for display in preview + result.calculatedPlaceholders = { + starttime: allGroups.starttime, + starttime24: allGroups.starttime24, + endtime: allGroups.endtime, + endtime24: allGroups.endtime24, + }; } catch (e) { - // If parsing fails, leave time/time24 as placeholders + // If parsing fails, leave starttime/endtime as placeholders console.error('Error formatting time:', e); } } @@ -367,6 +410,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { programPosterUrl, form.values.custom_properties?.timezone, form.values.custom_properties?.output_timezone, + form.values.custom_properties?.program_duration, ]); useEffect(() => { @@ -608,7 +652,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="title_template" name="title_template" label="Title Template" - description="Format the EPG title using extracted groups. Use {time} (12-hour: '10 PM') or {time24} (24-hour: '22:00'). Example: {league} - {team1} vs {team2} @ {time}" + description="Format the EPG title using extracted groups. Use {starttime} (12-hour: '10 PM'), {starttime24} (24-hour: '22:00'), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: {league} - {team1} vs {team2} ({starttime}-{endtime})" placeholder="{league} - {team1} vs {team2}" value={titleTemplate} onChange={(e) => { @@ -622,8 +666,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="description_template" name="description_template" label="Description Template" - description="Format the EPG description using extracted groups. Use {time} (12-hour) or {time24} (24-hour). Example: Watch {team1} take on {team2} at {time}!" - placeholder="Watch {team1} take on {team2} in this exciting {league} matchup at {time}!" + description="Format the EPG description using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: Watch {team1} take on {team2} from {starttime} to {endtime}!" + placeholder="Watch {team1} take on {team2} in this exciting {league} matchup from {starttime} to {endtime}!" minRows={2} value={descriptionTemplate} onChange={(e) => { @@ -652,8 +696,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="upcoming_title_template" name="upcoming_title_template" label="Upcoming Title Template" - description="Title for programs before the event starts. Use {time} (12-hour) or {time24} (24-hour). Example: {team1} vs {team2} starting at {time}." - placeholder="{team1} vs {team2} starting at {time}." + description="Title for programs before the event starts. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: {team1} vs {team2} starting at {starttime}." + placeholder="{team1} vs {team2} starting at {starttime}." value={upcomingTitleTemplate} onChange={(e) => { const value = e.target.value; @@ -669,8 +713,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="upcoming_description_template" name="upcoming_description_template" label="Upcoming Description Template" - description="Description for programs before the event. Use {time} (12-hour) or {time24} (24-hour). Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} at {time}!" - placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} at {time}!" + description="Description for programs before the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!" + placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!" minRows={2} value={upcomingDescriptionTemplate} onChange={(e) => { @@ -687,8 +731,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="ended_title_template" name="ended_title_template" label="Ended Title Template" - description="Title for programs after the event has ended. Use {time} (12-hour) or {time24} (24-hour). Example: {team1} vs {team2} started at {time}." - placeholder="{team1} vs {team2} started at {time}." + description="Title for programs after the event has ended. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: {team1} vs {team2} started at {starttime}." + placeholder="{team1} vs {team2} started at {starttime}." value={endedTitleTemplate} onChange={(e) => { const value = e.target.value; @@ -704,8 +748,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { id="ended_description_template" name="ended_description_template" label="Ended Description Template" - description="Description for programs after the event. Use {time} (12-hour) or {time24} (24-hour). Example: The {league} match between {team1} and {team2} started at {time}." - placeholder="The {league} match between {team1} and {team2} started at {time}." + description="Description for programs after the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), or {endtime24} (24-hour end). Example: The {league} match between {team1} and {team2} ran from {starttime} to {endtime}." + placeholder="The {league} match between {team1} and {team2} ran from {starttime} to {endtime}." minRows={2} value={endedDescriptionTemplate} onChange={(e) => { @@ -934,6 +978,42 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { )} + {/* Show calculated time placeholders when time is extracted */} + {patternValidation.timeMatch && + Object.keys(patternValidation.calculatedPlaceholders || {}) + .length > 0 && ( + + + Available Time Placeholders: + + + {Object.entries( + patternValidation.calculatedPlaceholders + ).map(([key, value]) => ( + + + {'{' + key + '}'}: + + + {value} + + + ))} + + + )} + {patternValidation.dateMatch && (