]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
DVR, HTSP: improve episode number storage and handling, fixes #4811
authorJaroslav Kysela <perex@perex.cz>
Fri, 12 Jan 2018 13:40:55 +0000 (14:40 +0100)
committerJaroslav Kysela <perex@perex.cz>
Fri, 12 Jan 2018 13:43:33 +0000 (14:43 +0100)
src/dvr/dvr.h
src/dvr/dvr_db.c
src/epg.c
src/epg.h
src/htsp_server.c

index 412e2585e49aa29bb938bda072d89ec50ddd2193..57794234ddd69ea278c317bb48d8dd980f4744a1 100644 (file)
@@ -232,7 +232,7 @@ typedef struct dvr_entry {
    */
   LIST_ENTRY(dvr_entry) de_bcast_link;
   epg_broadcast_t *de_bcast;
-  char *de_episode;
+  epg_episode_num_t de_epnum;
 
   /**
    * Major State
index d52453a4f2fe522e32893d7c80939d1d5440b7a9..cb79609bc77b2d121b0ea708065a5e0bf10375ef 100644 (file)
@@ -843,7 +843,7 @@ dvr_entry_fuzzy_match(dvr_entry_t *de, epg_broadcast_t *e, uint16_t eid, int64_t
 {
   time_t t1, t2;
   const char *title1, *title2;
-  char buf[64];
+  epg_episode_num_t epnum;
 
   /* Wrong length (+/-20%) */
   t1 = de->de_stop - de->de_start;
@@ -870,10 +870,68 @@ dvr_entry_fuzzy_match(dvr_entry_t *de, epg_broadcast_t *e, uint16_t eid, int64_t
     return 0;
 
   /* episode check */
-  if (dvr_entry_get_episode(e, buf, sizeof(buf)) && de->de_episode)
-    if (strcmp(buf, de->de_episode))
-      return 0;
+  epg_episode_get_epnum(e->episode, &epnum);
+  if (epg_episode_number_cmpfull(&epnum, &de->de_epnum))
+    return 0;
+
+  return 1;
+}
+
+/*
+ * Expect season and episode from an input string of
+ * "Season A/B.Episode Y/Z"
+ * or "Season A.Episode Y/Z"
+ * or "Season A.Episode Y"
+ * or some combination.
+ */
+static int extract_season_episode(epg_episode_num_t *epnum, const char *text)
+{
+  uint32_t s = 0, sc = 0, e = 0, ec = 0;
+  const char *ch = text;
+
+  memset(epnum, 0, sizeof(*epnum));
+
+  /* Extract season and season count */
+  if (strncasecmp(ch, "Season", 7))
+    goto _episode;
+
+  ch += 7;
+  for (; *ch == ' '; ch++);
+  for (; isdigit(*ch); ch++)
+    s = (s * 10) + (*ch - '0');
+  if (*ch == '/') {
+    for (ch++; isdigit(*ch); ch++)
+      sc = (sc * 10) + (*ch - '0');
+  }
+
+  /* Sanity check */
+  if (*ch != '.')
+    return 0;
 
+  /* Extract episode and episode count */
+_episode:
+  if (strncasecmp(ch, "Episode", 7))
+    return 0;
+  ch += 7;
+  for (; *ch == ' '; ch++);
+  for (; isdigit(*ch); ch++)
+    e = (e * 10) + (*ch - '0');
+  if (*ch == '/') {
+    for (ch++; isdigit(*ch); ch++)
+      ec = (ec * 10) + (*ch - '0');
+  }
+
+  /* end-of-string check */
+  if (*ch)
+    return 0;
+
+  if (e == 0)
+    return 0;
+
+  epnum->s_num = s;
+  epnum->s_cnt = sc;
+  epnum->e_num = e;
+  epnum->e_cnt = ec;
   return 1;
 }
 
@@ -887,6 +945,7 @@ dvr_entry_create(const char *uuid, htsmsg_t *conf, int clone)
   int64_t start, stop;
   htsmsg_t *m;
   char ubuf[UUID_HEX_SIZE];
+  const char *s;
 
   if (conf) {
     if (htsmsg_get_s64(conf, "start", &start))
@@ -915,6 +974,16 @@ dvr_entry_create(const char *uuid, htsmsg_t *conf, int clone)
 
   idnode_load(&de->de_id, conf);
 
+  /* Extract episode info */
+  s = htsmsg_get_str(conf, "episode");
+  if (s) {
+    extract_season_episode(&de->de_epnum, s);
+  } else {
+    m = htsmsg_get_map(conf, "episode");
+    if (m)
+      epg_episode_epnum_deserialize(m, &de->de_epnum);
+  }
+
   /* filenames */
   m = htsmsg_get_list(conf, "files");
   if (m)
@@ -1251,7 +1320,11 @@ typedef int (*_dvr_duplicate_fcn_t)(dvr_entry_t *de, dvr_entry_t *de2, void **au
 
 static int _dvr_duplicate_epnum(dvr_entry_t *de, dvr_entry_t *de2, void **aux)
 {
-  return !strempty(de2->de_episode) && !strcmp(de->de_episode, de2->de_episode);
+  if (de->de_epnum.e_num && de2->de_epnum.e_num)
+    return de->de_epnum.e_num == de2->de_epnum.e_num;
+  if (de->de_epnum.text && de2->de_epnum.text)
+    return strcmp(de->de_epnum.text, de2->de_epnum.text) == 0;
+  return 0;
 }
 
 static int _dvr_duplicate_title(dvr_entry_t *de, dvr_entry_t *de2, void **aux)
@@ -1351,38 +1424,6 @@ static const char *_dvr_duplicate_get_dedup_program_id(const dvr_entry_t *de)
   return NULL;
 }
 
-/// Expect season and episode from an input string of
-/// "Season A/B.Episode Y/Z"
-/// or "Season A.Episode Y/Z"
-/// or "Season A.Episode Y"
-/// or some combination.
-static int64_t extract_season_episode(const char* ep)
-{
-  /* Go to first digit of season */
-  const char *ch = ep;
-  while (*ch && !isdigit(*ch))
-    ++ch;
-
-  /* atoi on the season */
-  int s=0;
-  while (isdigit(*ch))
-    s = (s  * 10) + (*ch++ - '0');
-
-  /* Now we're either on / or . */
-  while (*ch &&  *ch != '.')
-      ++ch;
-
-  /* Now we're on Episode */
-  while (*ch && !isdigit(*ch))
-    ++ch;
-  int e=0;
-  while (isdigit(*ch))
-    e = (e * 10) + (*ch++ - '0');
-
-  /* Now combine it together */
-  return ((int64_t)s) << 32 | e;
-}
-
 /// @return 1 if dup.
 static int _dvr_duplicate_unique_match(dvr_entry_t *de1, dvr_entry_t *de2, void **aux)
 {
@@ -1402,41 +1443,18 @@ static int _dvr_duplicate_unique_match(dvr_entry_t *de1, dvr_entry_t *de2, void
   /* Titles not equal? Can't be a dup then */
   if (lang_str_compare(de1->de_title, de2->de_title)) return NOT_DUP;
 
-  /* Season and/or episode is stored in episode. But the numbers are
-   * not saved separately. We want to dup match only if both season
+  /* We want to dup match only if both season
    * AND episode are present since OTA often have just "Ep 1" without
    * giving the season.
    */
-  const char *s_ep1 = de1->de_episode;
-  const char *s_ep2 = de2->de_episode;
-
-  /* Are season AND episode both in the string? */
-  const int is_s_and_ep1 = s_ep1 && strstr(s_ep1, "Season") && strstr(s_ep1, "Episode");
-  const int is_s_and_ep2 = s_ep2 && strstr(s_ep2, "Season") && strstr(s_ep2, "Episode");
-
-  /* Season and episode are the same (for the same title) so must be a DUP.
-   * If they differ then must not be a DUP.
-   *
-   * We only compare up to the character before the slash since the
-   * default display is "Season X.Episode Y/Z" for xmltv, but OTA
-   * rarely has the Z component which means that we will frequently
-   * fail to match xmltv vs OTA unless we compare only the season and
-   * episode components and ignore the total number of episodes
-   * component.
-   *
-   * Newer tv_grab gives "Season X/X.Episode Y/Z" so we have to
-   * be careful with the compare.
-   */
-  if (is_s_and_ep1 && is_s_and_ep2) {
-    const int64_t sepnum1 = extract_season_episode(s_ep1);
-    const int64_t sepnum2 = extract_season_episode(s_ep2);
-    return sepnum1 == sepnum2 ? DUP : NOT_DUP;
-  }
+  if (de1->de_epnum.e_num && de2->de_epnum.e_num)
+    return de1->de_epnum.e_num == de2->de_epnum.e_num ? DUP : NOT_DUP;
 
   /* Only one has season and episode? Then can't be a dup with the
    * other one that doesn't have season+episode
    */
-  if ((is_s_and_ep1 && !is_s_and_ep2) || (!is_s_and_ep1 && is_s_and_ep2)) return NOT_DUP;
+  if (de1->de_epnum.e_num || de2->de_epnum.e_num)
+    return NOT_DUP;
 
   /* Now, near the end, we can check for unequal programme ids. We do
    * this check relatively late since we want dup checking above to
@@ -1483,7 +1501,8 @@ static int _dvr_duplicate_unique_match(dvr_entry_t *de1, dvr_entry_t *de2, void
    * for different region broadcasts so we want to match on episode details
    * first.
    */
-  if (do_progid_check && progid1 && progid2 && strcmp(progid1, progid2)) return NOT_DUP;
+  if (do_progid_check && progid1 && progid2 && strcmp(progid1, progid2))
+    return NOT_DUP;
 
   /* Only one side has an id? We do nothing for this case since programmes in old dvr/log
    * don't have a programid persisted.
@@ -1496,7 +1515,8 @@ static int _dvr_duplicate_unique_match(dvr_entry_t *de1, dvr_entry_t *de2, void
    * different crid so would be NOT_DUP at first check. But if everything is
    * identical then user should resort to using ONCE_PER_DAY rules, etc.
    */
-  if (!lang_str_compare(de1->de_subtitle, de2->de_subtitle) && !lang_str_compare(de1->de_desc, de2->de_desc))
+  if (!lang_str_compare(de1->de_subtitle, de2->de_subtitle) &&
+      !lang_str_compare(de1->de_desc, de2->de_desc))
     return DUP;
 
   /* If all tests have finished then we assume not a dup */
@@ -1545,7 +1565,7 @@ static dvr_entry_t *_dvr_duplicate_event(dvr_entry_t *de)
       break;
     case DVR_AUTOREC_RECORD_DIFFERENT_EPISODE_NUMBER:
     case DVR_AUTOREC_LRECORD_DIFFERENT_EPISODE_NUMBER:
-      if (strempty(de->de_episode))
+      if (de->de_epnum.e_num == 0 && de->de_epnum.text == NULL)
         return NULL;
       break;
     case DVR_AUTOREC_RECORD_DIFFERENT_SUBTITLE:
@@ -1742,7 +1762,7 @@ dvr_entry_dec_ref(dvr_entry_t *de)
   if (de->de_desc) lang_str_destroy(de->de_desc);
   dvr_entry_assign_broadcast(de, NULL);
   free(de->de_channel_name);
-  free(de->de_episode);
+  free(de->de_epnum.text);
   free(de->de_image);
   free(de->de_uri);
 
@@ -1923,6 +1943,7 @@ static dvr_entry_t *_dvr_entry_update
 {
   char buf[40];
   int save = 0, updated = 0;
+  epg_episode_num_t epnum;
 
   if (enabled >= 0) {
     enabled = !!enabled;
@@ -2091,11 +2112,15 @@ static dvr_entry_t *_dvr_entry_update
   }
 
   /* Episode */
-  if (!dvr_entry_get_episode(de->de_bcast, buf, sizeof(buf)))
-    buf[0] = '\0';
-  if (strcmp(de->de_episode ?: "", buf)) {
-    free(de->de_episode);
-    de->de_episode = strdup(buf);
+  if (de->de_bcast->episode) {
+    epg_episode_get_epnum(de->de_bcast->episode, &epnum);
+  } else {
+    memset(&epnum, 0, sizeof(epnum));
+  }
+  if (epg_episode_number_cmpfull(&de->de_epnum, &epnum)) {
+    de->de_epnum = epnum;
+    if (epnum.text)
+      de->de_epnum.text = strdup(epnum.text);
     save |= DVR_UPDATED_EPISODE;
   }
 
@@ -2540,6 +2565,7 @@ dvr_entry_class_save(idnode_t *self, char *filename, size_t fsize)
   int64_t s64;
 
   idnode_save(&de->de_id, m);
+  htsmsg_add_msg(m, "episode", epg_episode_epnum_serialize(&de->de_epnum));
   if (de->de_files) {
     l = htsmsg_create_list();
     HTSMSG_FOREACH(f, de->de_files)
@@ -3186,6 +3212,44 @@ dvr_entry_class_disp_description_get(void *o)
   return &prop_ptr;
 }
 
+static int
+dvr_entry_class_disp_episode_set(void *o, const void *v)
+{
+  dvr_entry_t *de = (dvr_entry_t *)o;
+  epg_episode_num_t epnum;
+  if (!extract_season_episode(&epnum, (const char *)v))
+    epnum.text = (char *)v;
+  if (epg_episode_number_cmpfull(&de->de_epnum, &epnum)) {
+    free(de->de_epnum.text);
+    de->de_epnum = epnum;
+    if (epnum.text)
+      de->de_epnum.text = strdup(epnum.text);
+    return 1;
+  }
+  return 0;
+}
+
+static const void *
+dvr_entry_class_disp_episode_get(void *o)
+{
+  dvr_entry_t *de = (dvr_entry_t *)o;
+  const char *lang;
+  char buf1[32], buf2[32];
+  if (de->de_epnum.e_num) {
+    lang = idnode_lang(o);
+    snprintf(buf1, sizeof(buf1), "%s %%d", tvh_gettext_lang(lang, N_("Season")));
+    snprintf(buf1, sizeof(buf1), "%s %%d", tvh_gettext_lang(lang, N_("Episode")));
+    epg_episode_epnum_format(&de->de_epnum, prop_sbuf, PROP_SBUF_LEN, NULL,
+                             buf1, ".", buf2, "/%d");
+    return &prop_sbuf_ptr;
+  } else if (de->de_epnum.text) {
+    prop_ptr = de->de_epnum.text;
+  } else {
+    prop_ptr = "";
+  }
+  return &prop_ptr;
+}
+
 static const void *
 dvr_entry_class_url_get(void *o)
 {
@@ -3872,11 +3936,12 @@ const idclass_t dvr_entry_class = {
     },
     {
       .type     = PT_STR,
-      .id       = "episode",
+      .id       = "episode_disp",
       .name     = N_("Episode"),
       .desc     = N_("Episode number/ID."),
-      .off      = offsetof(dvr_entry_t, de_episode),
-      .opts     = PO_RDONLY | PO_HIDDEN,
+      .set      = dvr_entry_class_disp_episode_set,
+      .get      = dvr_entry_class_disp_episode_get,
+      .opts     = PO_RDONLY | PO_HIDDEN | PO_NOSAVE,
     },
     {
       .type     = PT_STR,
index 75490b2a2826b5ca578fdfe6c4e175e3d34bfefd..229476ba275d3e84e11ec88dd8736fc3f6c189df 100644 (file)
--- a/src/epg.c
+++ b/src/epg.c
@@ -855,7 +855,7 @@ const char *epg_season_get_summary
  * Episode
  * *************************************************************************/
 
-static htsmsg_t *epg_episode_num_serialize ( epg_episode_num_t *num )
+htsmsg_t *epg_episode_epnum_serialize ( epg_episode_num_t *num )
 {
   htsmsg_t *m;
   if (!num) return NULL;
@@ -877,7 +877,7 @@ static htsmsg_t *epg_episode_num_serialize ( epg_episode_num_t *num )
   return m;
 }
 
-static void epg_episode_num_deserialize 
+void epg_episode_epnum_deserialize
   ( htsmsg_t *m, epg_episode_num_t *num )
 {
   const char *str;
@@ -1256,38 +1256,52 @@ static void _epg_episode_rem_broadcast
   _epg_object_putref(episode);
 }
 
-size_t epg_episode_number_format 
-  ( epg_episode_t *episode, char *buf, size_t len,
+size_t epg_episode_epnum_format
+  ( epg_episode_num_t *epnum, char *buf, size_t len,
     const char *pre,  const char *sfmt,
     const char *sep,  const char *efmt,
     const char *cfmt )
 {
   size_t i = 0;
-  if (!episode || !buf || !len) return 0;
-  epg_episode_num_t num;
-  epg_episode_get_epnum(episode, &num);
+  if (!epnum || !buf || !len) return 0;
   buf[0] = '\0';
-  if (num.e_num) {
+  if (epnum->e_num) {
     if (pre) tvh_strlcatf(buf, len, i, "%s", pre);
-    if (sfmt && num.s_num) {
-      tvh_strlcatf(buf, len, i, sfmt, num.s_num);
-      if (cfmt && num.s_cnt)
-        tvh_strlcatf(buf, len, i, cfmt, num.s_cnt);
+    if (sfmt && epnum->s_num) {
+      tvh_strlcatf(buf, len, i, sfmt, epnum->s_num);
+      if (cfmt && epnum->s_cnt)
+        tvh_strlcatf(buf, len, i, cfmt, epnum->s_cnt);
       if (sep) tvh_strlcatf(buf, len, i, "%s", sep);
     }
-    tvh_strlcatf(buf, len, i, efmt, num.e_num);
-    if (cfmt && num.e_cnt)
-      tvh_strlcatf(buf, len, i, cfmt, num.e_cnt);
-  } else if (num.text) {
+    tvh_strlcatf(buf, len, i, efmt, epnum->e_num);
+    if (cfmt && epnum->e_cnt)
+      tvh_strlcatf(buf, len, i, cfmt, epnum->e_cnt);
+  } else if (epnum->text) {
     if (pre) tvh_strlcatf(buf, len, i, "%s", pre);
-    tvh_strlcatf(buf, len, i, "%s", num.text);
+    tvh_strlcatf(buf, len, i, "%s", epnum->text);
   }
   return i;
 }
 
-void epg_episode_get_epnum ( epg_episode_t *ee, epg_episode_num_t *num )
+size_t epg_episode_number_format
+  ( epg_episode_t *episode, char *buf, size_t len,
+    const char *pre,  const char *sfmt,
+    const char *sep,  const char *efmt,
+    const char *cfmt )
 {
-  if (!ee || !num) return;
+  if (!episode) return 0;
+  epg_episode_num_t num;
+  epg_episode_get_epnum(episode, &num);
+  return epg_episode_epnum_format(&num, buf, len, pre,
+                                  sfmt, sep, efmt, cfmt);
+}
+
+void epg_episode_get_epnum ( const epg_episode_t *ee, epg_episode_num_t *num )
+{
+  if (!ee || !num) {
+    memset(num, 0, sizeof(*num));
+    return;
+  }
   *num = ee->epnum;
   if (ee->season) {
     num->e_cnt = ee->season->episode_count;
@@ -1298,7 +1312,7 @@ void epg_episode_get_epnum ( epg_episode_t *ee, epg_episode_num_t *num )
   }
 }
 
-int epg_episode_number_cmp ( epg_episode_num_t *a, epg_episode_num_t *b )
+int epg_episode_number_cmp ( const epg_episode_num_t *a, const epg_episode_num_t *b )
 {
   if (a->e_num) {
     if (a->s_num != b->s_num) {
@@ -1314,6 +1328,23 @@ int epg_episode_number_cmp ( epg_episode_num_t *a, epg_episode_num_t *b )
   return 0;
 }
 
+int epg_episode_number_cmpfull ( const epg_episode_num_t *a, const epg_episode_num_t *b )
+{
+  int i = a->s_cnt - b->s_cnt;
+  if (i) return i;
+  i = a->s_num - b->s_num;
+  if (i) return i;
+  i = a->e_cnt - b->e_cnt;
+  if (i) return i;
+  i = a->e_num - b->e_num;
+  if (i) return i;
+  i = a->p_cnt - b->p_cnt;
+  if (i) return i;
+  i = a->p_num - b->p_num;
+  if (i) return i;
+  return strcasecmp(a->text ?: "", b->text ?: "");
+}
+
 // WIBNI: this could do with soem proper matching, maybe some form of
 //        fuzzy string match. I did try a few things, but none of them
 //        were very reliable.
@@ -1343,7 +1374,7 @@ htsmsg_t *epg_episode_serialize ( epg_episode_t *episode )
     lang_str_serialize(episode->summary, m, "summary");
   if (episode->description)
     lang_str_serialize(episode->description, m, "description");
-  htsmsg_add_msg(m, "epnum", epg_episode_num_serialize(&episode->epnum));
+  htsmsg_add_msg(m, "epnum", epg_episode_epnum_serialize(&episode->epnum));
   LIST_FOREACH(eg, &episode->genre, link) {
     if (!a) a = htsmsg_create_list();
     htsmsg_add_u32(a, NULL, eg->code);
@@ -1405,7 +1436,7 @@ epg_episode_t *epg_episode_deserialize ( htsmsg_t *m, int create, int *save )
     lang_str_destroy(ls);
   }
   if ((sub = htsmsg_get_map(m, "epnum"))) {
-    epg_episode_num_deserialize(sub, &num);
+    epg_episode_epnum_deserialize(sub, &num);
     *save |= epg_episode_set_epnum(ee, &num, &changes);
     if (num.text) free(num.text);
   }
index 715923e77b42504dae852f12e70bd897fdba97d8..e5f8609721095e6472cc03187717cfd2da9181e6 100644 (file)
--- a/src/epg.h
+++ b/src/epg.h
@@ -420,7 +420,7 @@ int epg_episode_set_age_rating
 
 // Note: this does NOT strdup the text field
 void epg_episode_get_epnum
-  ( epg_episode_t *e, epg_episode_num_t *epnum );
+  ( const epg_episode_t *e, epg_episode_num_t *epnum );
 /* EpNum format helper */
 // output string will be:
 // if (episode_num) 
@@ -431,13 +431,23 @@ void epg_episode_get_epnum
 //   ret += sprintf(efmt, episode_num)
 //   if (episode_cnt) ret += sprintf(cfmt, episode_cnt)
 // and will return num chars written
+size_t epg_episode_epnum_format
+  ( epg_episode_num_t *epnum, char *buf, size_t len,
+    const char *pre,  const char *sfmt,
+    const char *sep,  const char *efmt,
+    const char *cfmt );
 size_t epg_episode_number_format 
   ( epg_episode_t *e, char *buf, size_t len,
     const char *pre,  const char *sfmt,
     const char *sep,  const char *efmt,
     const char *cfmt );
-int  epg_episode_number_cmp
-  ( epg_episode_num_t *a, epg_episode_num_t *b );
+int epg_episode_number_cmp
+  ( const epg_episode_num_t *a, const epg_episode_num_t *b );
+int epg_episode_number_cmpfull
+  ( const epg_episode_num_t *a, const epg_episode_num_t *b );
+
+htsmsg_t *epg_episode_epnum_serialize( epg_episode_num_t *num );
+void epg_episode_epnum_deserialize( htsmsg_t *m, epg_episode_num_t *num );
 
 /* Matching */
 int epg_episode_fuzzy_match
index 12bb2e99e67fe46f080475ef3349af0884c5832f..c2e2c77c17495b492ea47a19eaad4b9a6e7553aa 100644 (file)
@@ -687,6 +687,31 @@ htsp_serierec_convert(htsp_connection_t *htsp, htsmsg_t *in, channel_t *ch, int
   return conf;
 }
 
+/*
+ *
+ */
+static void htsp_serialize_epnum
+  (htsmsg_t *out, epg_episode_num_t *epnum, const char *textname)
+{
+  if (epnum->s_num) {
+    htsmsg_add_u32(out, "seasonNumber", epnum->s_num);
+    if (epnum->s_cnt)
+      htsmsg_add_u32(out, "seasonCount", epnum->s_cnt);
+  }
+  if (epnum->e_num) {
+    htsmsg_add_u32(out, "episodeNumber", epnum->e_num);
+    if (epnum->e_cnt)
+      htsmsg_add_u32(out, "episodeCount", epnum->e_cnt);
+  }
+  if (epnum->p_num) {
+    htsmsg_add_u32(out, "partNumber", epnum->p_num);
+    if (epnum->p_cnt)
+      htsmsg_add_u32(out, "partCount", epnum->p_cnt);
+  }
+  if (epnum->text)
+    htsmsg_add_str(out, textname ?: "episodeOnscreen", epnum->text);
+}
+
 /* **************************************************************************
  * File helpers
  * *************************************************************************/
@@ -995,8 +1020,7 @@ htsp_build_dvrentry(htsp_connection_t *htsp, dvr_entry_t *de, const char *method
       htsmsg_add_str(out, "subtitle", s);
     if(de->de_desc && (s = lang_str_get(de->de_desc, lang)))
       htsmsg_add_str(out, "description", s);
-    if(de->de_episode)
-      htsmsg_add_str(out, "episode", de->de_episode);
+    htsp_serialize_epnum(out, &de->de_epnum, "episode");
     if(de->de_owner)
       htsmsg_add_str(out, "owner",   de->de_owner);
     if(de->de_creator)
@@ -1283,23 +1307,7 @@ htsp_build_event
     if (ee->first_aired)
       htsmsg_add_s64(out, "firstAired", ee->first_aired);
     epg_episode_get_epnum(ee, &epnum);
-    if (epnum.s_num) {
-      htsmsg_add_u32(out, "seasonNumber", epnum.s_num);
-      if (epnum.s_cnt)
-        htsmsg_add_u32(out, "seasonCount", epnum.s_cnt);
-    }
-    if (epnum.e_num) {
-      htsmsg_add_u32(out, "episodeNumber", epnum.e_num);
-      if (epnum.e_cnt)
-        htsmsg_add_u32(out, "episodeCount", epnum.e_cnt);
-    }
-    if (epnum.p_num) {
-      htsmsg_add_u32(out, "partNumber", epnum.p_num);
-      if (epnum.p_cnt)
-        htsmsg_add_u32(out, "partCount", epnum.p_cnt);
-    }
-    if (epnum.text)
-      htsmsg_add_str(out, "episodeOnscreen", epnum.text);
+    htsp_serialize_epnum(out, &epnum, NULL);
     if (ee->image)
       htsmsg_add_str(out, "image", ee->image);
   }