]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
Add Scene Markers to recordings at scheduled EPG event start/stop times.
authorDeltaMikeCharlie <127641886+DeltaMikeCharlie@users.noreply.github.com>
Sun, 15 Jun 2025 02:45:48 +0000 (12:45 +1000)
committerFlole <Flole998@users.noreply.github.com>
Thu, 17 Jul 2025 09:37:09 +0000 (11:37 +0200)
src/dvr/dvr.h
src/dvr/dvr_config.c
src/dvr/dvr_cutpoints.c
src/dvr/dvr_db.c

index e07b681195e4d526d5f064e797ef45816ab372cc..d3692ab6e46204cd6f59199b6cb9aa6df4392e02 100644 (file)
@@ -115,6 +115,7 @@ typedef struct dvr_config {
   int dvr_episode_in_title;
   int dvr_clean_title;
   int dvr_tag_files;
+  int dvr_create_scene_markers;
   int dvr_skip_commercials;
   int dvr_subtitle_in_title;
   int dvr_windows_compatible_filenames;
@@ -637,6 +638,8 @@ const char *dvr_get_filename(dvr_entry_t *de);
 
 int64_t dvr_get_filesize(dvr_entry_t *de, int flags);
 
+int dvr_get_files_details(dvr_entry_t *de, time_t *files_start, time_t *files_stop, int *files_count);
+
 int64_t dvr_entry_claenup(dvr_entry_t *de, int64_t requiredBytes);
 
 void dvr_entry_set_rerecord(dvr_entry_t *de, int cmd);
@@ -883,6 +886,7 @@ void dvr_entry_trace_time2_(const char *file, int line,
  *
  */
 
+void dvr_create_recording_scene_markers(dvr_entry_t *de);
 void dvr_init(void);
 void dvr_config_init(void);
 
index 8160d351dd282a48192112e49f2707590effdcf3..b2eb76828b2394a981ca3f367889a8c0d4e5bc9c 100644 (file)
@@ -180,6 +180,7 @@ dvr_config_create(const char *name, const char *uuid, htsmsg_t *conf)
   cfg->dvr_removal_days = DVR_RET_REM_FOREVER;
   cfg->dvr_clone = 1;
   cfg->dvr_tag_files = 1;
+  cfg->dvr_create_scene_markers = 1;
   cfg->dvr_skip_commercials = 1;
   dvr_charset_update(cfg, intlconv_filesystem_charset());
   cfg->dvr_warm_time = 30;
@@ -1418,6 +1419,16 @@ const idclass_t dvr_config_class = {
       .def.i    = 1,
       .group    = 5,
     },
+    {
+      .type     = PT_BOOL,
+      .id       = "create-scene-markers",
+      .name     = N_("Create scene markers"),
+      .desc     = N_("Create scene markers in recordings "
+                     "based on the EPG start/stop times when available."),
+      .off      = offsetof(dvr_config_t, dvr_create_scene_markers),
+      .def.i    = 1,
+      .group    = 5,
+    },
     {
       .type     = PT_U32,
       .id       = "epg-update-window",
index e3101deb18fa4f0dbd309fb3451a850184234432..a209617359819816d24bec989d4a81c2bdc026dd 100644 (file)
@@ -185,20 +185,40 @@ done:
  *
  * // TODO: possibly could be better with some sort of auto-detect
  */
+
+/* DMC 2025 Notes
+ *
+ * I did some testing with Kodi mixing 'sm' and 'edl' records.
+ * The combined records do NOT need to be sorted, overlapping
+ * records of different types work fine from different files.
+ * However, in Kodi, mixing types within files can have unpredictable results.
+ * Keeping type 2 (scene markers) and type 3 (skip) in different files works.
+ * Kodi does not attempt to load cutpoints for 'radio' recordings.
+ */
+
 static struct {
   const char *ext;
   int        (*parse) (const char *path, dvr_cutpoint_list_t *, void *);
   void       *opaque;
+  int        merge;  //Allow merging.  If this parser has data, do not stop there.
 } dvr_cutpoint_parsers[] = {
+  {
+    .ext    = "sm",               // This is just an 'edl' file with an 'sm' extension containing
+    .parse  = dvr_parse_file,     // scene markers.  This is done first so that the results can be
+    .opaque = dvr_parse_edl,      // merged with following edl or txt skip records.
+    .merge  = 1,
+  },
   {
     .ext    = "txt",
     .parse  = dvr_parse_file,
     .opaque = dvr_parse_comskip,
+    .merge  = 0,
   },
   {
     .ext    = "edl",
     .parse  = dvr_parse_file,
     .opaque = dvr_parse_edl,
+    .merge  = 0,
   },
 };
 
@@ -212,6 +232,7 @@ dvr_get_cutpoint_list (dvr_entry_t *de)
   char *path, *sptr;
   const char *filename;
   dvr_cutpoint_list_t *cuts;
+  int found_count = 0;
 
   /* Check this is a valid recording */
   assert(de != NULL);
@@ -248,11 +269,18 @@ dvr_get_cutpoint_list (dvr_entry_t *de)
     /* Try parsing */
     if (dvr_cutpoint_parsers[i].parse(path, cuts,
                                       dvr_cutpoint_parsers[i].opaque) != -1)
-      break;
-  }
+    {
+      found_count++;
+      if(!dvr_cutpoint_parsers[i].merge)
+      {
+        break;
+      }
+    }
+  }//END loop through parsers
 
   /* Cleanup */
-  if (i >= ARRAY_SIZE(dvr_cutpoint_parsers)) {
+  if (found_count == 0)
+  {
     dvr_cutpoint_list_destroy(cuts);
     return NULL;
   }
index 431b820c23d9022c0073c435f765013c84b9a668..74cb834e6fd9b97c0a2f48877b7d8ceaee6fe4fb 100644 (file)
@@ -2910,6 +2910,175 @@ void dvr_event_running(epg_broadcast_t *e, epg_running_t running)
   }
 }
 
+/*
+ * Create an sm file for the dvr entry provided
+ * An 'sm' file is in edl file format, but contains the
+ * TVH-generated scene markers based on the scheduled
+ * EPG start/stop times.
+ * The cutpoint parser will merge these entries with the
+ * other cutpoint files found.
+ *
+ * SM/EDL file format
+ * [start time] [end time] [action]
+ *
+ * action = 2 is a 'scene marker'
+ *
+ * Kodi only recognises the end time: https://kodi.wiki/view/Edit_decision_list
+ * However, both start and stop are saved because other applications may use them.
+ *
+ * TODO - Investigate writing the start marker when the recording passes the epg start point
+ *        This could be handy when chase-playing a recording-in-progress.
+ */
+void
+dvr_create_recording_scene_markers(dvr_entry_t *de)
+{
+  //If writing an sm is not enabled for this dvr profile, then there is nothing to do.  Sayonara!
+  if(!de->de_config->dvr_create_scene_markers)
+  {
+    return;
+  }
+
+  tvhtrace(LS_DVR, "Creating scene markers");
+
+  time_t          file_start = 0;               //Recording file start timestamp
+  time_t          file_stop = 0;                //Recording file stop timestamp
+  int             temp_len = 0;                 //Length of the recording file name
+  int             temp_pos = 0;                 //Position in the filename to append the '.sm' extension
+  const char      *filename = NULL;             //Recording file name
+  int             filecount = 0;                //Number of files in this recording
+  char            *temp_filename = NULL;        //File name for the sm file
+  FILE            *sm_file;                     //File handle for the sm file
+  time_t          segment_1_start = 0;          //Start position (seconds) for the first marker
+  time_t          segment_1_stop = 0;           //Stop position (seconds) for the first marker
+  time_t          segment_2_start = 0;          //Start position (seconds) for the second marker
+  time_t          segment_2_stop = 0;           //Stop position (seconds) for the second marker
+
+  filename = dvr_get_filename(de);
+
+  //The file start/stop timestamps are not directly available from the main dvr record
+  //structure, they need to be obtained by reading through the recording file list
+  //and saving those values.
+  dvr_get_files_details(de, &file_start, &file_stop, &filecount);
+  
+  //If recording contains more than one file, don't process it.  ('too hard' basket).
+  //TODO - A lot more research is required into multiple files per recording.
+  //       How are they created?
+  //       Is there a gap in time between the files or are they contiguous?
+  //       If there is a gap, how should this be accounted for?
+  //       A. A multiple file situation can be forced by stopping TVH
+  //          part way through a recording and then starting it again
+  //          before the recording was due to end.
+  //          ?Perhaps each file should also have its own scene marker?
+  //          A 37 minute recording with a gap in the middle
+  //          will not yield 37 minutes of playable files.
+  //          It will be 37 minutes minus Y.
+  //
+  //             |--2-min ---|------30 minutes-----|----5-min---|
+  //             |--2-min ---|-X-|----Y-----|--Z---|----5-min---|
+  // |--warm-up--|--pre-pad--|--------event--------|--post-pad--|
+  // |------------------service-subscription--------------------|
+  //             |----file-1-----|--outage--|-----file-2--------|
+  //
+  // warm-up starts at:     dvr_entry_get_start_time(de, 1)
+  // file starts at:        file_start
+  // recording starts at:   dvr_entry_get_start_time(de, 0)
+  // EPG event starts at:   de->de_start
+  // EPG event stops at:    de->de_stop
+  // recording stops at:    dvr_entry_get_stop_time(de)
+  // file stops at:         file_stop
+  //
+  // Under ideal circumstances, this is what should happen:
+  // The event and padding are fully covered by the recording.
+  // |--warm-up--|--pre-pad--|--------event--------|--post-pad--|
+  // |------------------service-subscription--------------------|
+  //             |---------------recording----------------------|
+  //                         ^                     ^
+  //                    scene marker          scene marker
+  
+  if(de->de_start && de->de_stop && filecount == 1)
+  {
+    //Build a temporary file name for the SM file.
+    temp_len = strlen(filename);
+    temp_filename = calloc(1, temp_len + 8);  //Existing file name length plus some space.
+
+    if(!temp_filename)
+    {
+      tvherror(LS_DVR, "Unable to allocate space for sm file name.");
+      return;
+    }
+
+    //Find the position of the last dot before the extension in the file name
+    char *last_dot = strrchr(filename, '.');
+
+    if (!last_dot)
+    {
+      tvherror(LS_DVR, "Unable to locate extension in '%s'.", filename);
+      free(temp_filename);
+      return;
+    }
+    temp_pos = last_dot - filename;
+
+    strncpy(temp_filename, filename, temp_pos);     //Copy just the path and the base file name.
+    strcpy(temp_filename + temp_pos, ".sm");        //Add the extension to the end.
+
+    //If the event start is fully covered by the recording
+    if((file_start < de->de_start) && (file_stop > de->de_start))
+    {
+      segment_1_start = 0;
+      segment_1_stop = de->de_start - file_start;
+      tvhtrace(LS_DVR, "Writing event start marker: %"PRItime_t"/%"PRItime_t".", segment_1_start, segment_1_stop);
+    }
+
+    //If the event stop is fully covered by the recording
+    if((file_stop > de->de_stop) && (file_start < de->de_stop))
+    {
+      if(file_start > de->de_start)
+      {
+        segment_2_start = 0;
+      }
+      else
+      {
+        segment_2_start = de->de_start - file_start;
+      }
+      segment_2_stop = de->de_stop - file_start;
+      tvhtrace(LS_DVR, "Writing event stop marker: %"PRItime_t"/%"PRItime_t".", segment_2_start, segment_2_stop);
+    }
+
+    //Do we have any markers to write?
+    if(segment_1_start || segment_1_stop || segment_2_start || segment_2_stop)
+    {
+
+      //Open the SM file.
+      if (!(sm_file = tvh_fopen(temp_filename, "w")))
+      {
+        tvherror(LS_DVR, "Unable to create sm file '%s'.", temp_filename);
+        free(temp_filename);
+        return;      
+      }      
+
+      //If we have a first segment, write that marker.
+      //Consider making this a skip marker (type 3) in the future.
+      if(segment_1_start || segment_1_stop)
+      {
+        fprintf(sm_file, "%"PRItime_t" %"PRItime_t" 2\r\n", segment_1_start, segment_1_stop);
+      }
+
+      //If we have a second segment, write that marker.
+      if(segment_2_start || segment_2_stop)
+      {
+        fprintf(sm_file, "%"PRItime_t" %"PRItime_t" 2\r\n", segment_2_start, segment_2_stop);
+      }
+
+      fclose(sm_file);
+
+    }//END we got some markers to write.
+
+    free(temp_filename);    //Clean up the mess
+
+  }//END we are creating a cutpoint file.
+
+}//END dvr_create_recording_scene_markers
+
 /**
  *
  */
@@ -2948,6 +3117,9 @@ dvr_stop_recording(dvr_entry_t *de, int stopcode, int saveconf, int clone)
   // Trigger autorecord update in case of schedules limit
   if (dae && dvr_autorec_get_max_sched_count(dae) > 0)
     dvr_autorec_changed(de->de_autorec, 0);
+
+  //Create the sm file
+  dvr_create_recording_scene_markers(de);
 }
 
 
@@ -5030,6 +5202,54 @@ dvr_get_filesize(dvr_entry_t *de, int flags)
   return first ? -1 : res;
 }
 
+/**
+ * Get the minimum start time, maximum end time and file count
+ */
+int
+dvr_get_files_details(dvr_entry_t *de, time_t *files_start, time_t *files_stop, int *files_count)
+{
+  htsmsg_field_t *f;
+  htsmsg_t *m;
+
+  int64_t start = 0;
+  int64_t stop = 0;
+
+  time_t temp_start = 0;
+  time_t temp_stop = 0;
+  int temp_count = 0;
+
+  if (de->de_files == NULL)
+    return -1;
+
+  HTSMSG_FOREACH(f, de->de_files)
+  {
+    if ((m = htsmsg_field_get_map(f)) != NULL) {
+      
+      start = htsmsg_get_s64_or_default(m, "start", 0);
+      if(temp_start == 0 || ((start < temp_start) && (start != 0)))
+      {
+        temp_start = start;
+      }
+
+      stop = htsmsg_get_s64_or_default(m, "stop", 0);
+      if(temp_stop == 0 || ((stop > temp_stop) && (stop != 0)))
+      {
+        temp_stop = stop;
+      }
+
+      temp_count++;
+
+    }//END we got a map
+  }//END FOREACH
+
+  *files_start = temp_start;
+  *files_stop = temp_stop;
+  *files_count = temp_count;
+
+  return 0;
+
+}
+
 /**
  *
  */