Implement media feeds continue watching and play next.

See this part of the media feeds spec:
https://wicg.github.io/media-feeds/#play-next-tv-episodes

Some refactoring was necessary also. This is still missing the duration
on TV episode and the live details. Because those require changes to the
mojo structs, I will do them in a different CL.

The general process is:
1) Find all episodes in the Item and get them as EpisodeCandidate
2) Pick main and play next episodes from list of EpisodeCandidate
3) If main episode is present, validate and store it on item->tv_episode
4) If play next is present, validate and store it on
   item->play_next_candidate

It's OK for a TV series to have no EpisodeCandidates, but any it has
should be valid.

We should perhaps break this up into multiple files. Open to suggestions
on that.

Bug: 1068751
Change-Id: I37260ee8a905be347e764667921794bb845a4b77
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2146072
Reviewed-by: Becca Hughes <beccahughes@chromium.org>
Commit-Queue: Sam Bowen <sgbowen@google.com>
Cr-Commit-Position: refs/heads/master@{#758965}
diff --git a/chrome/browser/media/feeds/media_feeds_converter.cc b/chrome/browser/media/feeds/media_feeds_converter.cc
index ec100186..d39b916 100644
--- a/chrome/browser/media/feeds/media_feeds_converter.cc
+++ b/chrome/browser/media/feeds/media_feeds_converter.cc
@@ -138,15 +138,39 @@
   return !property.values->date_time_values.empty();
 }
 
-// Gets a number from the property which may be stored as a long or double.
-base::Optional<uint64_t> GetNumber(const Property& property) {
-  if (!property.values->long_values.empty())
-    return property.values->long_values[0];
-  if (!property.values->double_values.empty())
-    return lround(property.values->double_values[0]);
+// Gets a positive integer from the property which may be stored as a long or
+// double.
+base::Optional<uint64_t> GetPositiveIntegerFromProperty(
+    Entity* entity,
+    const std::string& property_name) {
+  auto* property = GetProperty(entity, property_name);
+  if (!property)
+    return base::nullopt;
+
+  if (!property->values->long_values.empty() &&
+      property->values->long_values[0] > 0) {
+    return property->values->long_values[0];
+  }
+
+  if (!property->values->double_values.empty() &&
+      property->values->double_values[0] > 0) {
+    return lround(property->values->double_values[0]);
+  }
+
   return base::nullopt;
 }
 
+// Gets the duration from the property and store the result in item. Returns
+// true if the duration was valid.
+template <typename T>
+bool GetDuration(const Property& property, T* item) {
+  if (property.values->time_values.empty())
+    return false;
+
+  item->duration = property.values->time_values[0];
+  return true;
+}
+
 // Gets a list of media images from the property. The property should have at
 // least one media image and no more than kMaxImages. A media image is either a
 // valid URL string or an ImageObject entity containing a width, height, and
@@ -337,9 +361,18 @@
     }
 
     auto* value = GetProperty(identifier.get(), schema_org::property::kValue);
-    if (!value || !IsNonEmptyString(*value))
+    if (!value)
       return false;
-    converted_identifier->value = value->values->string_values[0];
+
+    // The value must be a type we can unambiguously store as string.
+    if (!value->values->string_values.empty()) {
+      converted_identifier->value = value->values->string_values[0];
+    } else if (!value->values->long_values.empty()) {
+      converted_identifier->value =
+          base::NumberToString(value->values->long_values[0]);
+    } else {
+      return false;
+    }
 
     item->identifiers.push_back(std::move(converted_identifier));
   }
@@ -387,11 +420,8 @@
     if (!type.has_value() || item->interaction_counters.count(type.value()) > 0)
       return false;
 
-    auto* user_interaction_count =
-        GetProperty(stat.get(), schema_org::property::kUserInteractionCount);
-    if (!user_interaction_count)
-      return false;
-    base::Optional<uint64_t> count = GetNumber(*user_interaction_count);
+    base::Optional<uint64_t> count = GetPositiveIntegerFromProperty(
+        stat.get(), schema_org::property::kUserInteractionCount);
     if (!count.has_value())
       return false;
     item->interaction_counters.insert(
@@ -426,10 +456,45 @@
   return true;
 }
 
-// Gets the watchAction and actionStatus properties from an embedded entity and
-// stores the result in item. Returns true if both the action and the action
-// status were valid.
-bool GetActionAndStatus(const Property& property, mojom::MediaFeedItem* item) {
+// Gets the action status from embedded in the action property of the entity.
+base::Optional<mojom::MediaFeedItemActionStatus> GetActionStatus(
+    Entity* entity) {
+  auto* action = GetProperty(entity, schema_org::property::kPotentialAction);
+  if (!action || action->values->entity_values.empty())
+    return base::nullopt;
+
+  auto* action_status = GetProperty(action->values->entity_values[0].get(),
+                                    schema_org::property::kActionStatus);
+  if (!action_status)
+    return base::nullopt;
+  if (!IsUrl(*action_status))
+    return base::nullopt;
+
+  auto status = schema_org::enums::CheckValidEnumString(
+      "http://schema.org/ActionStatusType",
+      action_status->values->url_values[0]);
+  switch (status.value()) {
+    case static_cast<int>(
+        schema_org::enums::ActionStatusType::kActiveActionStatus):
+      return mojom::MediaFeedItemActionStatus::kActive;
+    case static_cast<int>(
+        schema_org::enums::ActionStatusType::kPotentialActionStatus):
+
+      return mojom::MediaFeedItemActionStatus::kPotential;
+    case static_cast<int>(
+        schema_org::enums::ActionStatusType::kCompletedActionStatus):
+      return mojom::MediaFeedItemActionStatus::kCompleted;
+  }
+
+  return base::nullopt;
+}
+
+// Gets the watchAction properties from an embedded entity and stores the result
+// in item. Returns true if the action was valid.
+template <typename T>
+bool GetAction(mojom::MediaFeedItemActionStatus action_status,
+               const Property& property,
+               T* item) {
   if (property.values->entity_values.empty())
     return false;
 
@@ -444,71 +509,52 @@
     return false;
   item->action->url = target->values->url_values[0];
 
-  auto* action_status =
-      GetProperty(action.get(), schema_org::property::kActionStatus);
-  if (action_status) {
-    if (!IsUrl(*action_status))
-      return false;
+  if (action_status == mojom::MediaFeedItemActionStatus::kUnknown)
+    return false;
 
-    auto status = schema_org::enums::CheckValidEnumString(
-        "http://schema.org/ActionStatusType",
-        action_status->values->url_values[0]);
-    if (status == base::nullopt) {
+  if (action_status == mojom::MediaFeedItemActionStatus::kActive) {
+    auto* start_time =
+        GetProperty(action.get(), schema_org::property::kStartTime);
+    if (!start_time || start_time->values->time_values.empty())
       return false;
-    } else if (status.value() ==
-               static_cast<int>(
-                   schema_org::enums::ActionStatusType::kActiveActionStatus)) {
-      item->action_status = mojom::MediaFeedItemActionStatus::kActive;
-      auto* start_time =
-          GetProperty(action.get(), schema_org::property::kStartTime);
-      if (!start_time || start_time->values->time_values.empty())
-        return false;
-      item->action->start_time = start_time->values->time_values[0];
-    } else if (status.value() ==
-               static_cast<int>(schema_org::enums::ActionStatusType::
-                                    kPotentialActionStatus)) {
-      item->action_status = mojom::MediaFeedItemActionStatus::kPotential;
-    } else if (status.value() ==
-               static_cast<int>(schema_org::enums::ActionStatusType::
-                                    kCompletedActionStatus)) {
-      item->action_status = mojom::MediaFeedItemActionStatus::kCompleted;
-    }
+    item->action->start_time = start_time->values->time_values[0];
   }
+
   return true;
 }
 
+// Represents a candidate for use as the item's main episode or play next
+// candidate.
+struct EpisodeCandidate {
+  Entity* entity;
+  mojom::MediaFeedItemActionStatus action_status;
+  int season_number;
+  int episode_number;
+};
+
 // Gets the TV episode stored in an embedded entity and stores the result in
 // item. Returns true if the TV episode was valid.
-bool GetEpisode(const Property& property, mojom::MediaFeedItem* item) {
-  if (property.values->entity_values.empty())
-    return false;
-
-  EntityPtr& episode = property.values->entity_values[0];
-  if (episode->type != schema_org::entity::kTVEpisode)
-    return false;
-
+bool GetEpisode(const EpisodeCandidate& candidate, mojom::MediaFeedItem* item) {
   if (!item->tv_episode)
     item->tv_episode = mojom::TVEpisode::New();
 
-  auto* episode_number =
-      GetProperty(episode.get(), schema_org::property::kEpisodeNumber);
-  if (!episode_number || !IsPositiveInteger(*episode_number))
-    return false;
-  item->tv_episode->episode_number = episode_number->values->long_values[0];
+  item->tv_episode->episode_number = candidate.episode_number;
+  item->tv_episode->season_number = candidate.season_number;
+  item->action_status = candidate.action_status;
 
-  auto* name = GetProperty(episode.get(), schema_org::property::kName);
+  auto* name = GetProperty(candidate.entity, schema_org::property::kName);
   if (!name || !IsNonEmptyString(*name))
     return false;
   item->tv_episode->name = name->values->string_values[0];
 
   if (!ConvertProperty<mojom::TVEpisode>(
-          episode.get(), item->tv_episode.get(),
+          candidate.entity, item->tv_episode.get(),
           schema_org::property::kIdentifier, false,
           base::BindOnce(&GetIdentifiers<mojom::TVEpisode>))) {
     return false;
   }
 
-  auto* image = GetProperty(episode.get(), schema_org::property::kImage);
+  auto* image = GetProperty(candidate.entity, schema_org::property::kImage);
   if (image) {
     auto converted_images = GetMediaImage(*image);
     if (!converted_images.has_value())
@@ -518,17 +564,207 @@
   }
 
   if (!ConvertProperty<mojom::MediaFeedItem>(
-          episode.get(), item, schema_org::property::kPotentialAction, true,
-          base::BindOnce(&GetActionAndStatus))) {
+          candidate.entity, item, schema_org::property::kPotentialAction, true,
+          base::BindOnce(&GetAction<mojom::MediaFeedItem>,
+                         candidate.action_status))) {
     return false;
   }
 
   return true;
 }
 
-// Gets the TV season stored in an embedded entity and stores the result in
-// item. Returns true if the TV season was valid.
-bool GetSeason(const Property& property, mojom::MediaFeedItem* item) {
+// Gets the PlayNextCandidate stored in an embedded entity and stores the result
+// in item. Returns true if the PlayNextCandidate was valid. See the spec for
+// this feature: https://wicg.github.io/media-feeds/#play-next-tv-episodes
+bool GetPlayNextCandidate(const EpisodeCandidate& candidate,
+                          mojom::MediaFeedItem* item) {
+  if (!item->play_next_candidate)
+    item->play_next_candidate = mojom::PlayNextCandidate::New();
+
+  item->play_next_candidate->episode_number = candidate.episode_number;
+  item->play_next_candidate->season_number = candidate.season_number;
+
+  auto* name = GetProperty(candidate.entity, schema_org::property::kName);
+  if (!name || !IsNonEmptyString(*name))
+    return false;
+  item->play_next_candidate->name = name->values->string_values[0];
+
+  if (!ConvertProperty<mojom::PlayNextCandidate>(
+          candidate.entity, item->play_next_candidate.get(),
+          schema_org::property::kIdentifier, false,
+          base::BindOnce(&GetIdentifiers<mojom::PlayNextCandidate>))) {
+    return false;
+  }
+
+  auto* image = GetProperty(candidate.entity, schema_org::property::kImage);
+  if (image) {
+    auto converted_images = GetMediaImage(*image);
+    if (!converted_images.has_value())
+      return false;
+    // TODO(sgbowen): Add an images field to TV episodes and store the converted
+    // images here.
+  }
+
+  if (!ConvertProperty<mojom::PlayNextCandidate>(
+          candidate.entity, item->play_next_candidate.get(),
+          schema_org::property::kPotentialAction, true,
+          base::BindOnce(&GetAction<mojom::PlayNextCandidate>,
+                         candidate.action_status))) {
+    return false;
+  }
+
+  if (!ConvertProperty<mojom::PlayNextCandidate>(
+          candidate.entity, item->play_next_candidate.get(),
+          schema_org::property::kDuration, true,
+          base::BindOnce(&GetDuration<mojom::PlayNextCandidate>))) {
+    return false;
+  }
+
+  return true;
+}
+
+// Converts the entity to an EpisodeCandidate.
+base::Optional<EpisodeCandidate> GetEpisodeCandidate(const EntityPtr& entity) {
+  if (entity->type != schema_org::entity::kTVEpisode)
+    return base::nullopt;
+
+  EpisodeCandidate candidate;
+  candidate.entity = entity.get();
+
+  auto action_status = GetActionStatus(entity.get());
+  if (!action_status)
+    return base::nullopt;
+  candidate.action_status = action_status.value();
+
+  auto episode_number = GetPositiveIntegerFromProperty(
+      entity.get(), schema_org::property::kEpisodeNumber);
+  if (!episode_number.has_value())
+    return base::nullopt;
+  candidate.episode_number = episode_number.value();
+
+  return candidate;
+}
+
+// Converts all the entity values in the property to episode candidates. Returns
+// base::nullopt if any of the entities are not valid episode candidates.
+base::Optional<std::vector<EpisodeCandidate>> GetEpisodeCandidatesFromProperty(
+    Property* property,
+    int season_number) {
+  std::vector<EpisodeCandidate> episodes;
+  if (!property) {
+    return episodes;
+  }
+  for (const EntityPtr& episode : property->values->entity_values) {
+    auto candidate = GetEpisodeCandidate(episode);
+    if (!candidate.has_value())
+      return base::nullopt;
+    candidate.value().season_number = season_number;
+    episodes.push_back(std::move(candidate.value()));
+  }
+  return episodes;
+}
+
+// Gets a list of EpisodeCandidates from the entity. These can be embedded
+// either in the season or episode properties. Returns base::nullopt if any
+// candidates were invalid.
+base::Optional<std::vector<EpisodeCandidate>> GetEpisodeCandidates(
+    Entity* entity) {
+  std::vector<EpisodeCandidate> candidates;
+  auto* seasons = GetProperty(entity, schema_org::property::kContainsSeason);
+  if (seasons) {
+    for (const auto& season : seasons->values->entity_values) {
+      auto season_number = GetPositiveIntegerFromProperty(
+          season.get(), schema_org::property::kSeasonNumber);
+      if (!season_number.has_value())
+        return base::nullopt;
+      auto season_episodes = GetEpisodeCandidatesFromProperty(
+          GetProperty(season.get(), schema_org::property::kEpisode),
+          season_number.value());
+      if (!season_episodes.has_value())
+        return base::nullopt;
+      candidates.insert(candidates.end(), season_episodes.value().begin(),
+                        season_episodes.value().end());
+    }
+  }
+
+  auto embedded_episodes = GetEpisodeCandidatesFromProperty(
+      GetProperty(entity, schema_org::property::kEpisode), 0);
+
+  if (!embedded_episodes.has_value())
+    return base::nullopt;
+
+  candidates.insert(candidates.end(), embedded_episodes.value().begin(),
+                    embedded_episodes.value().end());
+  return candidates;
+}
+
+// Picks the main episode for the item from a list of candidates. Returns
+// base::nullopt if there is no main episode.
+base::Optional<EpisodeCandidate> PickMainEpisode(
+    std::vector<EpisodeCandidate> candidates) {
+  if (candidates.empty())
+    return base::nullopt;
+
+  if (candidates.size() == 1)
+    return candidates[0];
+
+  std::vector<EpisodeCandidate> main_candidates;
+  std::copy_if(
+      candidates.begin(), candidates.end(), std::back_inserter(main_candidates),
+      [](const EpisodeCandidate& e) {
+        return e.action_status == mojom::MediaFeedItemActionStatus::kActive ||
+               e.action_status == mojom::MediaFeedItemActionStatus::kCompleted;
+      });
+
+  if (main_candidates.empty())
+    return base::nullopt;
+
+  return main_candidates[0];
+}
+
+// Picks the play next candidate for the item from a list of candidates. Returns
+// base::nullopt if there is no matching candidate.
+base::Optional<EpisodeCandidate> PickPlayNextCandidate(
+    std::vector<EpisodeCandidate> candidates,
+    const base::Optional<EpisodeCandidate>& main_episode,
+    const std::map<int, int>& number_of_episodes) {
+  if (!main_episode.has_value())
+    return base::nullopt;
+
+  // Try to find the number of episodes for the main episode's season so we know
+  // whether to look in the next season for the next episode. If we don't find
+  // it, just look for the next episode in the main episode's season.
+  auto find_num_episodes =
+      number_of_episodes.find(main_episode.value().season_number);
+  int next_episode = main_episode.value().episode_number + 1;
+  int next_season = main_episode.value().season_number;
+  if (find_num_episodes != number_of_episodes.end() &&
+      next_episode > find_num_episodes->second) {
+    next_episode = 1;
+    next_season++;
+  }
+
+  std::vector<EpisodeCandidate> next_candidates;
+  std::copy_if(
+      candidates.begin(), candidates.end(), std::back_inserter(next_candidates),
+      [](const EpisodeCandidate& e) {
+        return e.action_status == mojom::MediaFeedItemActionStatus::kPotential;
+      });
+  auto it = std::find_if(next_candidates.begin(), next_candidates.end(),
+                         [&](const EpisodeCandidate& e) {
+                           return e.episode_number == next_episode &&
+                                  e.season_number == next_season;
+                         });
+  if (it == next_candidates.end())
+    return base::nullopt;
+  return *it;
+}
+
+// Gets the TV season stored in an embedded entity and updates a map of (season
+// number)->(number of episodes). Returns true if the TV season was valid.
+// Embedded episodes are handled separately and not checked here.
+bool GetSeason(const Property& property,
+               std::map<int, int>* number_of_episodes) {
   if (property.values->entity_values.empty())
     return false;
 
@@ -536,25 +772,18 @@
   if (season->type != schema_org::entity::kTVSeason)
     return false;
 
-  if (!item->tv_episode)
-    item->tv_episode = mojom::TVEpisode::New();
-
-  auto* season_number =
-      GetProperty(season.get(), schema_org::property::kSeasonNumber);
-  if (!season_number || !IsPositiveInteger(*season_number))
-    return false;
-  item->tv_episode->season_number = season_number->values->long_values[0];
-
-  auto* number_episodes =
-      GetProperty(season.get(), schema_org::property::kNumberOfEpisodes);
-  if (!number_episodes || !IsPositiveInteger(*number_episodes))
+  auto season_number = GetPositiveIntegerFromProperty(
+      season.get(), schema_org::property::kSeasonNumber);
+  if (!season_number.has_value())
     return false;
 
-  if (!ConvertProperty<mojom::MediaFeedItem>(
-          season.get(), item, schema_org::property::kEpisode, false,
-          base::BindOnce(&GetIdentifiers<mojom::MediaFeedItem>))) {
+  auto number_episodes = GetPositiveIntegerFromProperty(
+      season.get(), schema_org::property::kNumberOfEpisodes);
+  if (!number_episodes.has_value())
     return false;
-  }
+
+  number_of_episodes->insert(
+      std::make_pair(season_number.value(), number_episodes.value()));
 
   return true;
 }
@@ -588,16 +817,6 @@
   return true;
 }
 
-// Gets the duration from the property and store the result in item. Returns
-// true if the duration was valid.
-bool GetDuration(const Property& property, mojom::MediaFeedItem* item) {
-  if (property.values->time_values.empty())
-    return false;
-
-  item->duration = property.values->time_values[0];
-  return true;
-}
-
 // Given the schema.org data_feed_items, iterate through and convert all feed
 // items into MediaFeedItemPtr types. Store the converted items in
 // converted_feed_items. Skips invalid feed items.
@@ -688,9 +907,9 @@
                                 base::BindOnce(&GetMediaItemAuthor))) {
         continue;
       }
-      if (!convert_property.Run(schema_org::property::kDuration,
-                                !converted_item->live,
-                                base::BindOnce(&GetDuration))) {
+      if (!convert_property.Run(
+              schema_org::property::kDuration, !converted_item->live,
+              base::BindOnce(&GetDuration<mojom::MediaFeedItem>))) {
         continue;
       }
       if (!convert_property.Run(schema_org::property::kPublication, false,
@@ -703,24 +922,40 @@
         continue;
       }
     } else if (converted_item->type == mojom::MediaFeedItemType::kTVSeries) {
-      if (!convert_property.Run(schema_org::property::kEpisode, false,
-                                base::BindOnce(&GetEpisode))) {
+      std::map<int, int> number_of_episodes;
+      auto* season =
+          GetProperty(item.get(), schema_org::property::kContainsSeason);
+      if (season && !GetSeason(*season, &number_of_episodes))
         continue;
-      }
-      if (!convert_property.Run(schema_org::property::kContainsSeason, false,
-                                base::BindOnce(&GetSeason))) {
+      auto episodes = GetEpisodeCandidates(item.get());
+      if (!episodes.has_value())
+        continue;
+      auto main_episode = PickMainEpisode(episodes.value());
+      if (main_episode.has_value() &&
+          !GetEpisode(main_episode.value(), converted_item.get()))
+        continue;
+      auto next_episode = PickPlayNextCandidate(episodes.value(), main_episode,
+                                                number_of_episodes);
+      if (next_episode.has_value() &&
+          !GetPlayNextCandidate(next_episode.value(), converted_item.get())) {
         continue;
       }
     }
 
     bool has_embedded_action =
         item->type == schema_org::entity::kTVSeries && converted_item->action;
-    if (!convert_property.Run(schema_org::property::kPotentialAction,
-                              !has_embedded_action,
-                              base::BindOnce(&GetActionAndStatus))) {
-      continue;
+    if (!has_embedded_action) {
+      auto action_status = GetActionStatus(item.get());
+      if (!action_status.has_value())
+        continue;
+      converted_item->action_status = action_status.value();
+      if (!convert_property.Run(schema_org::property::kPotentialAction,
+                                !has_embedded_action,
+                                base::BindOnce(&GetAction<mojom::MediaFeedItem>,
+                                               action_status.value()))) {
+        continue;
+      }
     }
-
     converted_feed_items->push_back(std::move(converted_item));
   }
 }
diff --git a/chrome/browser/media/feeds/media_feeds_converter_unittest.cc b/chrome/browser/media/feeds/media_feeds_converter_unittest.cc
index 4a0583a..c4f6dd58 100644
--- a/chrome/browser/media/feeds/media_feeds_converter_unittest.cc
+++ b/chrome/browser/media/feeds/media_feeds_converter_unittest.cc
@@ -5,6 +5,7 @@
 #include "chrome/browser/media/feeds/media_feeds_converter.h"
 
 #include "base/strings/utf_string_conversions.h"
+#include "base/time/time.h"
 #include "chrome/browser/media/feeds/media_feeds_store.mojom-forward.h"
 #include "chrome/browser/media/feeds/media_feeds_store.mojom-shared.h"
 #include "chrome/browser/media/feeds/media_feeds_store.mojom.h"
@@ -33,7 +34,8 @@
   MediaFeedsConverterTest()
       : extractor_({schema_org::entity::kCompleteDataFeed,
                     schema_org::entity::kMovie,
-                    schema_org::entity::kWatchAction}) {}
+                    schema_org::entity::kWatchAction,
+                    schema_org::entity::kTVEpisode}) {}
 
  protected:
   Property* GetProperty(Entity* entity, const std::string& name);
@@ -47,8 +49,10 @@
   PropertyPtr CreateDoubleProperty(const std::string& name, double value);
   PropertyPtr CreateEntityProperty(const std::string& name, EntityPtr value);
   EntityPtr ConvertJSONToEntityPtr(const std::string& json);
-  EntityPtr ValidWatchAction();
+  EntityPtr ValidActiveWatchAction();
+  EntityPtr ValidPotentialWatchAction();
   EntityPtr ValidMediaFeed();
+  EntityPtr ValidEpisode(int episode_number, EntityPtr action);
   EntityPtr ValidMediaFeedItem();
   mojom::MediaFeedItemPtr ExpectedFeedItem();
   EntityPtr AddItemToFeed(EntityPtr feed, EntityPtr item);
@@ -144,7 +148,7 @@
   return extractor_.Extract(json);
 }
 
-EntityPtr MediaFeedsConverterTest::ValidWatchAction() {
+EntityPtr MediaFeedsConverterTest::ValidActiveWatchAction() {
   return extractor_.Extract(
       R"END(
       {
@@ -156,6 +160,18 @@
     )END");
 }
 
+EntityPtr MediaFeedsConverterTest::ValidPotentialWatchAction() {
+  return extractor_.Extract(
+      R"END(
+      {
+        "@type": "WatchAction",
+        "target": "https://www.example.com",
+        "actionStatus": "https://schema.org/PotentialActionStatus",
+        "startTime": "01:00:00"
+      }
+    )END");
+}
+
 EntityPtr MediaFeedsConverterTest::ValidMediaFeed() {
   return extractor_.Extract(
       R"END(
@@ -176,6 +192,28 @@
       )END");
 }
 
+EntityPtr MediaFeedsConverterTest::ValidEpisode(int episode_number,
+                                                EntityPtr action) {
+  EntityPtr episode = extractor_.Extract(
+      R"END(
+        {
+          "@type": "TVEpisode",
+          "name": "Pilot",
+          "identifier": {
+            "@type": "PropertyValue",
+            "propertyID": "TMS_ROOT_ID",
+            "value": "1"
+          },
+          "duration": "PT1H"
+        }
+      )END");
+  episode->properties.push_back(
+      CreateLongProperty(schema_org::property::kEpisodeNumber, episode_number));
+  episode->properties.push_back(CreateEntityProperty(
+      schema_org::property::kPotentialAction, std::move(action)));
+  return episode;
+}
+
 EntityPtr MediaFeedsConverterTest::ValidMediaFeedItem() {
   EntityPtr item = extractor_.Extract(
       R"END(
@@ -190,7 +228,7 @@
       )END");
 
   item->properties.push_back(CreateEntityProperty(
-      schema_org::property::kPotentialAction, ValidWatchAction()));
+      schema_org::property::kPotentialAction, ValidActiveWatchAction()));
   return item;
 }
 
@@ -679,31 +717,10 @@
   // embedded in the TV episode instead.
   GetProperty(item.get(), schema_org::property::kPotentialAction)->name =
       "not an action";
-  item->properties.push_back(
-      CreateLongProperty(schema_org::property::kNumberOfEpisodes, 20));
-  item->properties.push_back(
-      CreateLongProperty(schema_org::property::kNumberOfSeasons, 6));
 
-  {
-    EntityPtr episode = Entity::New();
-    episode->type = schema_org::entity::kTVEpisode;
-    episode->properties.push_back(
-        CreateLongProperty(schema_org::property::kEpisodeNumber, 1));
-    episode->properties.push_back(
-        CreateStringProperty(schema_org::property::kName, "Pilot"));
-    EntityPtr identifier = Entity::New();
-    identifier->type = schema_org::entity::kPropertyValue;
-    identifier->properties.push_back(
-        CreateStringProperty(schema_org::property::kPropertyID, "TMS_ROOT_ID"));
-    identifier->properties.push_back(
-        CreateStringProperty(schema_org::property::kValue, "1"));
-    episode->properties.push_back(CreateEntityProperty(
-        schema_org::property::kIdentifier, std::move(identifier)));
-    episode->properties.push_back(CreateEntityProperty(
-        schema_org::property::kPotentialAction, ValidWatchAction()));
-    item->properties.push_back(CreateEntityProperty(
-        schema_org::property::kEpisode, std::move(episode)));
-  }
+  item->properties.push_back(
+      CreateEntityProperty(schema_org::property::kEpisode,
+                           ValidEpisode(1, ValidPotentialWatchAction())));
 
   EntityPtr entity = AddItemToFeed(ValidMediaFeed(), std::move(item));
 
@@ -716,6 +733,9 @@
   identifier->type = mojom::Identifier::Type::kTMSRootId;
   identifier->value = "1";
   expected_item->tv_episode->identifiers.push_back(std::move(identifier));
+  expected_item->action_status = mojom::MediaFeedItemActionStatus::kPotential;
+  expected_item->action->start_time = base::nullopt;
+  expected_item->action->url = GURL("https://www.example.com");
 
   auto result = GetResults(std::move(entity));
 
@@ -740,7 +760,7 @@
   episode->properties.push_back(
       CreateStringProperty(schema_org::property::kName, ""));
   episode->properties.push_back(CreateEntityProperty(
-      schema_org::property::kPotentialAction, ValidWatchAction()));
+      schema_org::property::kPotentialAction, ValidActiveWatchAction()));
   item->properties.push_back(
       CreateEntityProperty(schema_org::property::kEpisode, std::move(episode)));
 
@@ -755,10 +775,6 @@
 TEST_F(MediaFeedsConverterTest, SucceedsItemWithTVSeason) {
   EntityPtr item = ValidMediaFeedItem();
   item->type = schema_org::entity::kTVSeries;
-  item->properties.push_back(
-      CreateLongProperty(schema_org::property::kNumberOfEpisodes, 20));
-  item->properties.push_back(
-      CreateLongProperty(schema_org::property::kNumberOfSeasons, 6));
 
   {
     EntityPtr season = Entity::New();
@@ -775,8 +791,6 @@
 
   mojom::MediaFeedItemPtr expected_item = ExpectedFeedItem();
   expected_item->type = mojom::MediaFeedItemType::kTVSeries;
-  expected_item->tv_episode = mojom::TVEpisode::New();
-  expected_item->tv_episode->season_number = 1;
 
   auto result = GetResults(std::move(entity));
 
@@ -812,4 +826,194 @@
   EXPECT_TRUE(result.value().empty());
 }
 
+TEST_F(MediaFeedsConverterTest, SucceedsItemWithPlayNextTwoSeasons) {
+  EntityPtr item = ValidMediaFeedItem();
+  item->type = schema_org::entity::kTVSeries;
+
+  PropertyPtr property = Property::New();
+  property->name = schema_org::property::kContainsSeason;
+  property->values = Values::New();
+
+  {
+    EntityPtr season = Entity::New();
+    season->type = schema_org::entity::kTVSeason;
+    season->properties.push_back(
+        CreateLongProperty(schema_org::property::kSeasonNumber, 1));
+    season->properties.push_back(
+        CreateLongProperty(schema_org::property::kNumberOfEpisodes, 20));
+    season->properties.push_back(
+        CreateEntityProperty(schema_org::property::kEpisode,
+                             ValidEpisode(20, ValidActiveWatchAction())));
+    property->values->entity_values.push_back(std::move(season));
+  }
+  {
+    EntityPtr season = Entity::New();
+    season->type = schema_org::entity::kTVSeason;
+    season->properties.push_back(
+        CreateLongProperty(schema_org::property::kSeasonNumber, 2));
+    season->properties.push_back(
+        CreateLongProperty(schema_org::property::kNumberOfEpisodes, 20));
+    season->properties.push_back(
+        CreateEntityProperty(schema_org::property::kEpisode,
+                             ValidEpisode(1, ValidPotentialWatchAction())));
+    property->values->entity_values.push_back(std::move(season));
+  }
+  item->properties.push_back(std::move(property));
+
+  EntityPtr entity = AddItemToFeed(ValidMediaFeed(), std::move(item));
+
+  mojom::MediaFeedItemPtr expected_item = ExpectedFeedItem();
+  expected_item->type = mojom::MediaFeedItemType::kTVSeries;
+  {
+    expected_item->tv_episode = mojom::TVEpisode::New();
+    expected_item->tv_episode->episode_number = 20;
+    expected_item->tv_episode->season_number = 1;
+    expected_item->tv_episode->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->tv_episode->identifiers.push_back(std::move(identifier));
+  }
+  {
+    expected_item->play_next_candidate = mojom::PlayNextCandidate::New();
+    expected_item->play_next_candidate->episode_number = 1;
+    expected_item->play_next_candidate->season_number = 2;
+    expected_item->play_next_candidate->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->play_next_candidate->identifiers.push_back(
+        std::move(identifier));
+    expected_item->play_next_candidate->duration =
+        base::TimeDelta::FromHours(1);
+    expected_item->play_next_candidate->action = mojom::Action::New();
+    expected_item->play_next_candidate->action->url =
+        GURL("https://www.example.com");
+  }
+
+  auto result = GetResults(std::move(entity));
+
+  EXPECT_TRUE(result.has_value());
+  ASSERT_EQ(result.value().size(), 1u);
+  EXPECT_EQ(expected_item, result.value()[0]);
+}
+
+TEST_F(MediaFeedsConverterTest, SucceedsItemWithPlayNextSameSeason) {
+  EntityPtr item = ValidMediaFeedItem();
+  item->type = schema_org::entity::kTVSeries;
+
+  EntityPtr season = Entity::New();
+  season->type = schema_org::entity::kTVSeason;
+  season->properties.push_back(
+      CreateLongProperty(schema_org::property::kSeasonNumber, 1));
+  season->properties.push_back(
+      CreateLongProperty(schema_org::property::kNumberOfEpisodes, 20));
+
+  PropertyPtr property = Property::New();
+  property->name = schema_org::property::kEpisode;
+  property->values = Values::New();
+  property->values->entity_values.push_back(
+      ValidEpisode(15, ValidActiveWatchAction()));
+  property->values->entity_values.push_back(
+      ValidEpisode(16, ValidPotentialWatchAction()));
+  season->properties.push_back(std::move(property));
+
+  item->properties.push_back(CreateEntityProperty(
+      schema_org::property::kContainsSeason, std::move(season)));
+
+  EntityPtr entity = AddItemToFeed(ValidMediaFeed(), std::move(item));
+
+  mojom::MediaFeedItemPtr expected_item = ExpectedFeedItem();
+  expected_item->type = mojom::MediaFeedItemType::kTVSeries;
+  {
+    expected_item->tv_episode = mojom::TVEpisode::New();
+    expected_item->tv_episode->episode_number = 15;
+    expected_item->tv_episode->season_number = 1;
+    expected_item->tv_episode->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->tv_episode->identifiers.push_back(std::move(identifier));
+  }
+  {
+    expected_item->play_next_candidate = mojom::PlayNextCandidate::New();
+    expected_item->play_next_candidate->episode_number = 16;
+    expected_item->play_next_candidate->season_number = 1;
+    expected_item->play_next_candidate->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->play_next_candidate->identifiers.push_back(
+        std::move(identifier));
+    expected_item->play_next_candidate->duration =
+        base::TimeDelta::FromHours(1);
+    expected_item->play_next_candidate->action = mojom::Action::New();
+    expected_item->play_next_candidate->action->url =
+        GURL("https://www.example.com");
+  }
+
+  auto result = GetResults(std::move(entity));
+
+  EXPECT_TRUE(result.has_value());
+  ASSERT_EQ(result.value().size(), 1u);
+  EXPECT_EQ(result.value()[0]->tv_episode, expected_item->tv_episode);
+  EXPECT_EQ(result.value()[0]->play_next_candidate,
+            expected_item->play_next_candidate);
+  EXPECT_EQ(expected_item, result.value()[0]);
+}
+
+TEST_F(MediaFeedsConverterTest, SucceedsItemWithPlayNextNoSeason) {
+  EntityPtr item = ValidMediaFeedItem();
+  item->type = schema_org::entity::kTVSeries;
+
+  PropertyPtr property = Property::New();
+  property->name = schema_org::property::kEpisode;
+  property->values = Values::New();
+  property->values->entity_values.push_back(
+      ValidEpisode(15, ValidActiveWatchAction()));
+  property->values->entity_values.push_back(
+      ValidEpisode(16, ValidPotentialWatchAction()));
+  item->properties.push_back(std::move(property));
+
+  EntityPtr entity = AddItemToFeed(ValidMediaFeed(), std::move(item));
+
+  mojom::MediaFeedItemPtr expected_item = ExpectedFeedItem();
+  expected_item->type = mojom::MediaFeedItemType::kTVSeries;
+  {
+    expected_item->tv_episode = mojom::TVEpisode::New();
+    expected_item->tv_episode->episode_number = 15;
+    expected_item->tv_episode->season_number = 0;
+    expected_item->tv_episode->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->tv_episode->identifiers.push_back(std::move(identifier));
+  }
+  {
+    expected_item->play_next_candidate = mojom::PlayNextCandidate::New();
+    expected_item->play_next_candidate->episode_number = 16;
+    expected_item->play_next_candidate->season_number = 0;
+    expected_item->play_next_candidate->name = "Pilot";
+    mojom::IdentifierPtr identifier = mojom::Identifier::New();
+    identifier->type = mojom::Identifier::Type::kTMSRootId;
+    identifier->value = "1";
+    expected_item->play_next_candidate->identifiers.push_back(
+        std::move(identifier));
+    expected_item->play_next_candidate->duration =
+        base::TimeDelta::FromHours(1);
+    expected_item->play_next_candidate->action = mojom::Action::New();
+    expected_item->play_next_candidate->action->url =
+        GURL("https://www.example.com");
+  }
+
+  auto result = GetResults(std::move(entity));
+
+  EXPECT_TRUE(result.has_value());
+  ASSERT_EQ(result.value().size(), 1u);
+  EXPECT_EQ(result.value()[0]->tv_episode, expected_item->tv_episode);
+  EXPECT_EQ(result.value()[0]->play_next_candidate,
+            expected_item->play_next_candidate);
+  EXPECT_EQ(expected_item, result.value()[0]);
+}
+
 }  // namespace media_feeds
diff --git a/components/schema_org/extractor.cc b/components/schema_org/extractor.cc
index 3af88bd..46dd6818 100644
--- a/components/schema_org/extractor.cc
+++ b/components/schema_org/extractor.cc
@@ -76,6 +76,12 @@
   schema_org::property::PropertyConfiguration prop_config =
       schema_org::property::GetPropertyConfiguration(property_type);
   if (prop_config.number) {
+    int64_t l;
+    bool parsed_long = base::StringToInt64(value, &l);
+    if (parsed_long) {
+      values->long_values.push_back(l);
+      return true;
+    }
     double d;
     bool parsed_double = base::StringToDouble(value, &d);
     if (parsed_double) {
diff --git a/components/schema_org/extractor_unittest.cc b/components/schema_org/extractor_unittest.cc
index 01d33c6f..d36e058 100644
--- a/components/schema_org/extractor_unittest.cc
+++ b/components/schema_org/extractor_unittest.cc
@@ -200,6 +200,19 @@
   EXPECT_EQ(expected, extracted);
 }
 
+TEST_F(SchemaOrgExtractorTest, StringValueRepresentingLong) {
+  EntityPtr extracted =
+      Extract("{\"@type\": \"VideoObject\",\"copyrightYear\": \"1999\"}");
+
+  ASSERT_FALSE(extracted.is_null());
+
+  EntityPtr expected = Entity::New();
+  expected->type = "VideoObject";
+  expected->properties.push_back(CreateLongProperty("copyrightYear", 1999));
+
+  EXPECT_EQ(expected, extracted);
+}
+
 TEST_F(SchemaOrgExtractorTest, StringValueRepresentingDouble) {
   EntityPtr extracted =
       Extract("{\"@type\": \"VideoObject\",\"copyrightYear\": \"1999.5\"}");