Repository: xbmc/xbmc
Pull request #26750: [Video][Bluray] Further improvements to episode matching heuristics.
Author: login: 78andyp
Labels: name: Type: Improvement
description: non-breaking change which improves existing functionality
name: Component: Video
name: v22 Piers
Created: 2025-05-06T16:21:44Z | Merged: 2025-05-12T17:05:57Z

## Description
<!--- Provide a general summary of your change in the Pull Request title above -->
<!--- Describe your change in detail here. -->

Handles several corner cases:
- The Expanse R1 S1D2 - multiple episodes in a single playlist
- Rome R2 S1D1 - group of playlists larger than number of episodes (includes extras)
- Hawkeye R2 S1D1- group of playlists contains multiple identical episode playlists
- Vikings R2 S1D1 - two play all playlists

## Motivation and context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

Improved episode matching.

## How has this been tested?
<!--- Please describe in detail how you tested your change. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

Locally

## What is the effect on users?
<!--- Summarize the effect of this change on Kodi end-users. -->
<!--- If the PR does not have a noticeable impact (e.g., if it only changes documentation), -->
<!--- just leave it empty. Put in more detail the bigger the impact is. -->
<!--- This section may be used for automatic creation of release notes. -->

## Screenshots (if appropriate):

## Types of change
<!--- What type of change does your code introduce? Put an `x` with no space in all the boxes that apply like this: [X] -->
- [ ] **Bug fix** (non-breaking change which fixes an issue)
- [ ] **Clean up** (non-breaking change which removes non-working, unmaintained functionality)
- [X] **Improvement** (non-breaking change which improves existing functionality)
- [ ] **New feature** (non-breaking change which adds functionality)
- [ ] **Breaking change** (fix or feature that will cause existing functionality to change)
- [ ] **Cosmetic change** (non-breaking change that doesn't touch code)
- [ ] **Student submission** (PR was done for educational purposes and will be treated as such)
- [ ] **None of the above** (please explain below)

## Checklist:
<!--- Go over all the following points, and put an `X` with no space in all the boxes that apply like this: [X] -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [X] My code follows the **[Code Guidelines](https://github.com/xbmc/xbmc/blob/master/docs/CODE_GUIDELINES.md)** of this project 
- [ ] My change requires a change to the documentation, either Doxygen or wiki
- [ ] I have updated the documentation accordingly
- [X] I have read the **[Contributing](https://github.com/xbmc/xbmc/blob/master/docs/CONTRIBUTING.md)** document
- [ ] I have added tests to cover my change
- [X] All new and existing tests passed



Diff:
diff --git a/xbmc/filesystem/DiscDirectoryHelper.cpp b/xbmc/filesystem/DiscDirectoryHelper.cpp
index 06d3ac13be845..63ce633e3910b 100644
--- a/xbmc/filesystem/DiscDirectoryHelper.cpp
+++ b/xbmc/filesystem/DiscDirectoryHelper.cpp
@@ -21,8 +21,10 @@
 #include <array>
 #include <chrono>
 #include <iterator>
+#include <numeric>
 #include <ranges>
 #include <set>
+#include <string>
 
 using namespace XFILE;
 using namespace std::chrono_literals;
@@ -76,6 +78,18 @@ void CDiscDirectoryHelper::InitialisePlaylistSearch(
   }
 }
 
+bool CDiscDirectoryHelper::IsValidSingleEpisodePlaylist(
+    const PlaylistInfo& singleEpisodePlaylistInformation, unsigned int clip) const
+{
+  // See if potential single episode playlist contains too many clips
+  // If there are 3 clips then expect the middle clip to be the main episode clip
+  // If there are numEpisodes clips this could be another play all playlist
+  return singleEpisodePlaylistInformation.clips.size() < 3 ||
+         (singleEpisodePlaylistInformation.clips.size() == 3 &&
+          singleEpisodePlaylistInformation.clips[1] == clip) ||
+         singleEpisodePlaylistInformation.clips.size() == m_numEpisodes;
+}
+
 void CDiscDirectoryHelper::FindPlayAllPlaylists(const ClipMap& clips, const PlaylistMap& playlists)
 {
   // Look for a potential play all playlist (gives episode order)
@@ -162,11 +176,8 @@ void CDiscDirectoryHelper::FindPlayAllPlaylists(const ClipMap& clips, const Play
           const auto& singleEpisodePlaylistInformation{
               playlists.find(singleEpisodePlaylist)->second};
 
-          // See if potential single episode playlist contains too many clips
-          // If there are 3 clips then expect the middle clip to be the main episode clip
-          if (singleEpisodePlaylistInformation.clips.size() > 3 ||
-              (singleEpisodePlaylistInformation.clips.size() == 3 &&
-               singleEpisodePlaylistInformation.clips[1] != clip))
+          // Check the playlist could be a single episode
+          if (!IsValidSingleEpisodePlaylist(singleEpisodePlaylistInformation, clip))
           {
             allClipsQualify = false;
             break;
@@ -175,6 +186,9 @@ void CDiscDirectoryHelper::FindPlayAllPlaylists(const ClipMap& clips, const Play
         }
       }
       playAllPlaylistClipMap[clip] = playAllPlaylistMap;
+
+      if (!allClipsQualify)
+        break;
     }
 
     // Found potential play all playlist
@@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
-      m_groups.clear();
-      const auto keys{std::views::keys(longPlaylists)};
-      m_groups.emplace_back(keys.begin(), keys.end());
+      std::ranges::transform(
+          longPlaylists, std::back_inserter(m_groups),
+          [](const auto& PlaylistInformation) -> std::vector<CandidatePlaylistInformation>
+          {
+            const auto& [playlist, playlistInformation] = PlaylistInformation;
+            return {{.playlist = playlist,
+                     .duration = playlistInformation.duration,
+                     .clips = {},
+                     .languages = {}}};
+          });
     }
 
     if (m_groups.empty())
       CLog::LogF(LOGDEBUG, "No playlist groups found");
     else
       for (const auto& group : m_groups)
-        CLog::LogF(LOGDEBUG, "Playlist group found from {} to {}", group.front(), group.back());
+        CLog::LogF(LOGDEBUG, "Playlist group found from {} to {}", group.front().playlist,
+                   group.back().playlist);
   }
   else
   {
@@ -309,13 +325,11 @@ void CDiscDirectoryHelper::UsePlayAllPlaylistMethod(unsigned int episodeIndex,
 
         m_candidatePlaylists.emplace(
             singleEpisodePlaylist,
-            CandidatePlaylistInformation{
-                .playlist = singleEpisodePlaylist,
-                .index = i + m_numSpecials,
-                .duration = singleEpisodePlaylistInformation.duration,
-                .clips = singleEpisodePlaylistInformation.clips,
-                .languages = singleEpisodePlaylistInformation
-                                 .languages}); // Also save episodeIndex for all episodes
+            CandidatePlaylistInformation{.playlist = singleEpisodePlaylist,
+                                         .index = i + m_numSpecials,
+                                         .duration = singleEpisodePlaylistInformation.duration,
+                                         .clips = singleEpisodePlaylistInformation.clips,
+                                         .languages = singleEpisodePlaylistInformation.languages});
       }
     }
     ++i;
@@ -384,40 +398,161 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroups.emplace_back()),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+  }
+  return uniqueGroups;
+}
+
+void CDiscDirectoryHelper::GetPlaylistsFromGroup(
+    unsigned int episodeIndex, const std::vector<CandidatePlaylistInformation>& group)
+{
+  for (unsigned int i = 0; i < m_numEpisodes; ++i)
+  {
+    if (m_allEpisodes != AllEpisodes::ALL &&
+        i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      continue;
+
+    m_candidatePlaylists.emplace(group[i].playlist,
+                                 CandidatePlaylistInformation{.playlist = group[i].playlist,
+                                                              .index = i + m_numSpecials,
+                                                              .duration = group[i].duration,
+                                                              .clips = group[i].clips,
+                                                              .languages = group[i].languages});
+    CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i].playlist);
+  }
+}
+
+void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex,
+                                          const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Method 2a - More than one episode on disc
 
   // Use groups and find nth playlist (or all for all episodes) in group
   // Groups are already contain at least numEpisodes playlists of minimum duration
-  for (const auto& group : m_groups)
+  // Firstly look just at groups that contain exactly numEpisodes playlists
+  CLog::LogF(LOGDEBUG, "Using group method - exact number of playlists");
+  const std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
+  for (const auto& group : groups)
   {
     if (group.size() != m_numEpisodes)
       continue;
 
-    for (unsigned int i = 0; i < m_numEpisodes; ++i)
+    GetPlaylistsFromGroup(episodeIndex, group);
+  }
+
+  if (m_candidatePlaylists.empty())
+  {
+    // Now look for groups that contain more than numEpisodes playlists
+    // Check that the first numEpisodes playlists have a duration within 20% of the desired episode
+    CLog::LogF(LOGDEBUG, "Using group method - relaxed number of playlists");
+    const std::chrono::milliseconds duration{episodesOnDisc[episodeIndex].GetDuration() * 1000ms};
+    for (const auto& group : groups)
     {
-      if (m_allEpisodes != AllEpisodes::ALL &&
-          i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      if (group.size() <= m_numEpisodes)
         continue;
 
-      const auto& playlistInformation{playlists.at(group[i])};
-      m_candidatePlaylists.emplace(
-          group[i],
-          CandidatePlaylistInformation{
-              .playlist = group[i],
-              .index = i + m_numSpecials,
-              .duration = playlistInformation.duration,
-              .clips = playlistInformation.clips,
-              .languages =
-                  playlistInformation.languages}); // Also save episodeIndex for all episodes
-      CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i]);
+      // Check duration
+      constexpr double WITHIN_PERCENT{20.0};
+      if (!std::ranges::all_of(group | std::views::take(m_numEpisodes) |
+                                   std::views::transform(&CandidatePlaylistInformation::duration),
+                               [duration](std::chrono::milliseconds d)
+                               { return abs(d - duration) < (WITHIN_PERCENT / 100.0 * duration); }))
+        continue;
+
+      GetPlaylistsFromGroup(episodeIndex, group);
     }
   }
+
+  if (m_candidatePlaylists.empty())
+    CLog::LogF(LOGDEBUG, "No candidate playlists found");
+}
+
+int CDiscDirectoryHelper::CalculateMultiple(std::chrono::milliseconds duration,
+                                            std::chrono::milliseconds averageShortest,
+                                            double multiplePercent)
+{
+  double multiple{static_cast<double>(duration.count()) /
+                  static_cast<double>(averageShortest.count())};
+  int integerMultiple{static_cast<int>(std::round(multiple))};
+  if (abs(multiple - integerMultiple) < multiplePercent / 100.0)
+    return integerMultiple;
+  return 0;
+}
+
+void CDiscDirectoryHelper::UseGroupsWithMultiplesMethod(
+    unsigned int episodeIndex, const std::vector<CVideoInfoTag>& episodesOnDisc)
+{
+  // No groups of numEpisodes length so see if there could be double episode playlists
+  // Assume more than one playlist
+  CLog::LogF(LOGDEBUG, "Using groups with multiples method");
+  for (auto& group : GetGroupsWithoutDuplicates(m_allGroups))
+  {
+    // Calculate multiples
+    // Find shortest playlist in group
+    const std::chrono::milliseconds shortest = std::ranges::min(
+        group | std::views::transform(&CandidatePlaylistInformation::duration), {});
+
+    // Then calculate the average of shortest (within 20% of the shortest) playlists
+    constexpr double SHORTEST_PERCENT{20.0};
+    auto groupDurations{
+        group | std::views::transform(&CandidatePlaylistInformation::duration) |
+        std::views::filter([shortest](const std::chrono::milliseconds i)
+                           { return i < shortest * (1 + (SHORTEST_PERCENT / 100.0)); })};
+    const std::chrono::milliseconds averageShortest{
+        std::accumulate(groupDurations.begin(), groupDurations.end(), 0ms) /
+        std::ranges::distance(groupDurations)};
+
+    // Multiples of average shortest playlists (within 15% of the average)
+    constexpr double MULTIPLE_PERCENT{15.0};
+    for (auto& playlist : group)
+      playlist.multiple = CalculateMultiple(playlist.duration, averageShortest, MULTIPLE_PERCENT);
+
+    // Check there are no playlists that are not a multiple
+    if (std::ranges::any_of(group,
+                            [](const CandidatePlaylistInformation& i) { return i.multiple == 0; }))
+      continue;
+
+    // Check that multiples add up to numEpisodes
+    auto groupMultiples{group | std::views::transform(&CandidatePlaylistInformation::multiple)};
+    if (std::accumulate(groupMultiples.begin(), groupMultiples.end(), 0) !=
+        static_cast<int>(m_numEpisodes))
+      continue;
+
+    // Save candidate episode(s)
+    unsigned int index{
+        m_numSpecials}; // Start at numSpecials as specials (S00) are before episodes in episodesOnDisc
+    for (const auto& playlist : group)
+    {
+      auto playlistInformation{playlist};
+      for (int i = 0; i < playlist.multiple; ++i)
+      {
+        if (m_allEpisodes == AllEpisodes::ALL || index == episodeIndex - m_numSpecials)
+        {
+          playlistInformation.index = index;
+          m_candidatePlaylists.emplace(playlist.playlist, playlistInformation);
+          CLog::LogF(LOGDEBUG, "Candidate playlist {} for episode {}", playlist.playlist,
+                     episodesOnDisc[index].m_iEpisode);
+        }
+        ++index;
+      }
+    }
+    break;
+  }
+
+  if (m_candidatePlaylists.empty())
+    CLog::LogF(LOGDEBUG, "No candidate playlists found");
 }
 
 void CDiscDirectoryHelper::ChooseSingleBestPlaylist(
-    const std::vector<CVideoInfoTag>& episodesOnDisc, const PlaylistMap& playlists)
+    const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Rebuild candidatePlaylists
   auto oldCandidatePlaylists{std::move(m_candidatePlaylists)};
@@ -509,13 +644,16 @@ void CDiscDirectoryHelper::FindCandidatePlaylists(const std::vector<CVideoInfoTa
   else if (m_numEpisodes == 1)
     UseLongOrCommonMethodForSingleEpisode(episodeIndex, playlists);
   else if (!m_groups.empty())
-    UseGroupMethod(episodeIndex, playlists);
+    UseGroupMethod(episodeIndex, episodesOnDisc);
+
+  if (m_candidatePlaylists.empty() && !m_allGroups.empty() && m_numEpisodes > 1)
+    UseGroupsWithMultiplesMethod(episodeIndex, episodesOnDisc);
 
   // Now deal with the possibility there may be more than one playlist (per episode)
   // For this, see which is closest in duration to the desired episode duration (from the scraper)
   // If we are looking for a special then leave all potential episode playlists in array
   if (m_candidatePlaylists.size() > 1 && m_isSpecial == IsSpecial::EPISODE)
-    ChooseSingleBestPlaylist(episodesOnDisc, playlists);
+    ChooseSingleBestPlaylist(episodesOnDisc);
 
   // candidatePlaylists should now (ideally) contain one candidate title for the episode or all episodes
   // Now look at durations of found playlist and add identical (in case language options)
diff --git a/xbmc/filesystem/DiscDirectoryHelper.h b/xbmc/filesystem/DiscDirectoryHelper.h
index e7e8e38e5a043..82b3fc6b9552b 100644
--- a/xbmc/filesystem/DiscDirectoryHelper.h
+++ b/xbmc/filesystem/DiscDirectoryHelper.h
@@ -139,12 +139,27 @@ class CDiscDirectoryHelper
     std::vector<unsigned int> clips;
     std::string languages;
 
+    // Used for inserting into a set where playlist is the key
     bool operator<(const CandidatePlaylistInformation& rhs) const noexcept
     {
       return playlist < rhs.playlist;
     }
   };
 
+  // For removing duplicates from a vector of CandidatePlaylistInformation
+  struct CandidatePlaylistInformationNotDuplicate
+  {
+    explicit CandidatePlaylistInformationNotDuplicate(std::set<int64_t>& seen) : m_seen(seen) {}
+
+    bool operator()(const CandidatePlaylistInformation& c) const noexcept
+    {
+      return m_seen.insert(c.duration.count()).second;
+    }
+
+  private:
+    std::set<int64_t>& m_seen;
+  };
+
 public:
   CDiscDirectoryHelper();
   CDiscDirectoryHelper(const CDiscDirectoryHelper&) = delete;
@@ -184,14 +199,24 @@ class CDiscDirectoryHelper
 
 private:
   void InitialisePlaylistSearch(int episodeIndex, const std::vector<CVideoInfoTag>& episodesOnDisc);
+  bool IsValidSingleEpisodePlaylist(const PlaylistInfo& singleEpisodePlaylistInformation,
+                                    unsigned int clip) const;
   void FindPlayAllPlaylists(const ClipMap& clips, const PlaylistMap& playlists);
   void FindGroups(const PlaylistMap& playlists);
   void UsePlayAllPlaylistMethod(unsigned int episodeIndex, const PlaylistMap& playlists);
   void UseLongOrCommonMethodForSingleEpisode(unsigned int episodeIndex,
                                              const PlaylistMap& playlists);
-  void UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists);
-  void ChooseSingleBestPlaylist(const std::vector<CVideoInfoTag>& episodesOnDisc,
-                                const PlaylistMap& playlists);
+  static std::vector<std::vector<CandidatePlaylistInformation>> GetGroupsWithoutDuplicates(
+      const std::vector<std::vector<CandidatePlaylistInformation>>& groups);
+  void GetPlaylistsFromGroup(unsigned int episodeIndex,
+                             const std::vector<CandidatePlaylistInformation>& group);
+  void UseGroupMethod(unsigned int episodeIndex, const std::vector<CVideoInfoTag>& episodesOnDisc);
+  static int CalculateMultiple(std::chrono::milliseconds duration,
+                               std::chrono::milliseconds averageShortest,
+                               double multiplePercent);
+  void UseGroupsWithMultiplesMethod(unsigned int episodeIndex,
+                                    const std::vector<CVideoInfoTag>& episodesOnDisc);
+  void ChooseSingleBestPlaylist(const std::vector<CVideoInfoTag>& episodesOnDisc);
   void AddIdenticalPlaylists(const PlaylistMap& playlists);
   void FindCandidatePlaylists(const std::vector<CVideoInfoTag>& episodesOnDisc,
                               unsigned int episodeIndex,
@@ -217,7 +242,7 @@ class CDiscDirectoryHelper
 
   std::set<CandidatePlaylistInformation> m_playAllPlaylists;
   std::map<unsigned int, std::map<unsigned int, std::vector<unsigned int>>> m_playAllPlaylistsMap;
-  std::vector<std::vector<unsigned int>> m_groups;
+  std::vector<std::vector<CandidatePlaylistInformation>> m_groups;
   std::vector<std::vector<CandidatePlaylistInformation>> m_allGroups;
   std::map<unsigned int, CandidatePlaylistInformation> m_candidatePlaylists;
   std::set<unsigned int> m_candidateSpecials;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the episode matching heuristics in DiscDirectoryHelper by consolidating playlist candidate information and improving grouping logic to handle corner cases with duplicate and multiple playlist entries.  
- Refactored CandidatePlaylistInformation handling and grouping methods using a new struct and duplicate elimination helper.  
- Revised methods for processing groups and calculating multiples for improved episode matching.

### Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

| File | Description |
| ---- | ----------- |
| xbmc/filesystem/DiscDirectoryHelper.h | Added a duplicate removal functor and updated group representation from unsigned int to CandidatePlaylistInformation. |
| xbmc/filesystem/DiscDirectoryHelper.cpp | Updated the group formation logic, introduced a new GetGroupsWithoutDuplicates method, and adjusted the group and multiples logic in candidate playlist selection. |




state: COMMENTED
author_association: NONE
submitted_at: 2025-05-06T20:25:15Z
user: login: neo1973
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T00:02:52Z
user: login: 78andyp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:18:05Z
user: login: 78andyp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:21:12Z
user: login: 78andyp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:28:15Z
user: login: 78andyp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:28:22Z
user: login: 78andyp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:30:09Z
user: login: neo1973
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-10T10:21:14Z
user: login: neo1973
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T10:42:28Z

Review comments:
diff_hunk: @@ -384,40 +388,159 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
+  }
+  return uniqueGroups;
+}
+
+void CDiscDirectoryHelper::GetPlaylistsFromGroup(
+    unsigned int episodeIndex, const std::vector<CandidatePlaylistInformation>& group)
+{
+  for (unsigned int i = 0; i < m_numEpisodes; ++i)
+  {
+    if (m_allEpisodes != AllEpisodes::ALL &&
+        i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      continue;
+
+    m_candidatePlaylists.emplace(group[i].playlist,
+                                 CandidatePlaylistInformation{.playlist = group[i].playlist,
+                                                              .index = i + m_numSpecials,
+                                                              .duration = group[i].duration,
+                                                              .clips = group[i].clips,
+                                                              .languages = group[i].languages});
+    CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i].playlist);
+  }
+}
+
+void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex,
+                                          const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Method 2a - More than one episode on disc
 
   // Use groups and find nth playlist (or all for all episodes) in group
   // Groups are already contain at least numEpisodes playlists of minimum duration
-  for (const auto& group : m_groups)
+  // Firstly look just at groups that contain exactly numEpisodes playlists
+  CLog::LogF(LOGDEBUG, "Using group method - exact number of playlists");
+  std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
+  for (const auto& group : groups)
   {
     if (group.size() != m_numEpisodes)
       continue;
 
-    for (unsigned int i = 0; i < m_numEpisodes; ++i)
+    GetPlaylistsFromGroup(episodeIndex, group);
+  }
+
+  if (m_candidatePlaylists.empty())
+  {
+    // Now look for groups that contain more than numEpisodes playlists
+    // Check that the first numEpisodes playlists have a duration within 20% of the desired episode
+    CLog::LogF(LOGDEBUG, "Using group method - relaxed number of playlists");
+    const std::chrono::milliseconds duration{episodesOnDisc[episodeIndex].GetDuration() * 1000ms};
+    for (const auto& group : groups)
     {
-      if (m_allEpisodes != AllEpisodes::ALL &&
-          i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      if (group.size() <= m_numEpisodes)
+        continue;
+
+      // Check duration
+      constexpr double WITHIN_PERCENT{20.0};
+      if (!std::ranges::all_of(group | std::views::take(m_numEpisodes) |
+                                   std::views::transform(&CandidatePlaylistInformation::duration),
+                               [duration](std::chrono::milliseconds d)
+                               { return abs(d - duration) < (WITHIN_PERCENT / 100.0 * duration); }))
         continue;
 
-      const auto& playlistInformation{playlists.at(group[i])};
-      m_candidatePlaylists.emplace(
-          group[i],
-          CandidatePlaylistInformation{
-              .playlist = group[i],
-              .index = i + m_numSpecials,
-              .duration = playlistInformation.duration,
-              .clips = playlistInformation.clips,
-              .languages =
-                  playlistInformation.languages}); // Also save episodeIndex for all episodes
-      CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i]);
+      GetPlaylistsFromGroup(episodeIndex, group);
     }
   }
+
+  if (m_candidatePlaylists.empty())
+    CLog::LogF(LOGDEBUG, "No candidate playlists found");
+}
+
+void CDiscDirectoryHelper::UseGroupsWithMultiplesMethod(
+    unsigned int episodeIndex, const std::vector<CVideoInfoTag>& episodesOnDisc)
+{
+  // No groups of numEpisodes length so see if there could be double episode playlists
+  // Assume more than one playlist
+  CLog::LogF(LOGDEBUG, "Using groups with multiples method");
+  for (auto& group : GetGroupsWithoutDuplicates(m_allGroups))
+  {
+    // Calculate multiples
+    // Find shortest playlist in group
+    const std::chrono::milliseconds shortest = std::ranges::min(
+        group | std::views::transform(&CandidatePlaylistInformation::duration), {});
+
+    // Then calculate the average of shortest (within 20% of the shortest) playlists
+    constexpr double SHORTEST_PERCENT{20.0};
+    auto groupDurations{
+        group | std::views::transform(&CandidatePlaylistInformation::duration) |
+        std::views::filter([shortest](const std::chrono::milliseconds i)
+                           { return i < shortest * (1 + (SHORTEST_PERCENT / 100.0)); })};
+    const std::chrono::milliseconds averageShortest{
+        std::accumulate(groupDurations.begin(), groupDurations.end(), 0ms) /
+        std::ranges::distance(groupDurations)};
+
+    // Multiples of average shortest playlists (within 15% of the average)
+    constexpr double MULTIPLE_PERCENT{15.0};
+    for (auto& playlist : group)
+    {
+      double multiple{static_cast<double>(playlist.duration.count()) /
+                      static_cast<double>(averageShortest.count())};
+      int integerMultiple{static_cast<int>(std::round(multiple))};
+
+      // .multiple defaults to 0 if not within MULTIPLE_PERCENT
+      if (abs(multiple - integerMultiple) < MULTIPLE_PERCENT / 100.0)
+        playlist.multiple = integerMultiple;
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: Copilot
body: [nitpick] Consider extracting the multiple calculation logic (lines 496-504) into a helper function to reduce complexity and improve readability.
```suggestion
      playlist.multiple = CalculateMultiple(playlist.duration, averageShortest, MULTIPLE_PERCENT);
```
created_at: 2025-05-06T20:25:15Z
author_association: NONE
diff_hunk: @@ -384,40 +388,159 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
+  }
+  return uniqueGroups;
+}
+
+void CDiscDirectoryHelper::GetPlaylistsFromGroup(
+    unsigned int episodeIndex, const std::vector<CandidatePlaylistInformation>& group)
+{
+  for (unsigned int i = 0; i < m_numEpisodes; ++i)
+  {
+    if (m_allEpisodes != AllEpisodes::ALL &&
+        i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      continue;
+
+    m_candidatePlaylists.emplace(group[i].playlist,
+                                 CandidatePlaylistInformation{.playlist = group[i].playlist,
+                                                              .index = i + m_numSpecials,
+                                                              .duration = group[i].duration,
+                                                              .clips = group[i].clips,
+                                                              .languages = group[i].languages});
+    CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i].playlist);
+  }
+}
+
+void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex,
+                                          const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Method 2a - More than one episode on disc
 
   // Use groups and find nth playlist (or all for all episodes) in group
   // Groups are already contain at least numEpisodes playlists of minimum duration
-  for (const auto& group : m_groups)
+  // Firstly look just at groups that contain exactly numEpisodes playlists
+  CLog::LogF(LOGDEBUG, "Using group method - exact number of playlists");
+  std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
+  for (const auto& group : groups)
   {
     if (group.size() != m_numEpisodes)
       continue;
 
-    for (unsigned int i = 0; i < m_numEpisodes; ++i)
+    GetPlaylistsFromGroup(episodeIndex, group);
+  }
+
+  if (m_candidatePlaylists.empty())
+  {
+    // Now look for groups that contain more than numEpisodes playlists
+    // Check that the first numEpisodes playlists have a duration within 20% of the desired episode
+    CLog::LogF(LOGDEBUG, "Using group method - relaxed number of playlists");
+    const std::chrono::milliseconds duration{episodesOnDisc[episodeIndex].GetDuration() * 1000ms};
+    for (const auto& group : groups)
     {
-      if (m_allEpisodes != AllEpisodes::ALL &&
-          i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      if (group.size() <= m_numEpisodes)
+        continue;
+
+      // Check duration
+      constexpr double WITHIN_PERCENT{20.0};
+      if (!std::ranges::all_of(group | std::views::take(m_numEpisodes) |
+                                   std::views::transform(&CandidatePlaylistInformation::duration),
+                               [duration](std::chrono::milliseconds d)
+                               { return abs(d - duration) < (WITHIN_PERCENT / 100.0 * duration); }))
         continue;
 
-      const auto& playlistInformation{playlists.at(group[i])};
-      m_candidatePlaylists.emplace(
-          group[i],
-          CandidatePlaylistInformation{
-              .playlist = group[i],
-              .index = i + m_numSpecials,
-              .duration = playlistInformation.duration,
-              .clips = playlistInformation.clips,
-              .languages =
-                  playlistInformation.languages}); // Also save episodeIndex for all episodes
-      CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i]);
+      GetPlaylistsFromGroup(episodeIndex, group);
     }
   }
+
+  if (m_candidatePlaylists.empty())
+    CLog::LogF(LOGDEBUG, "No candidate playlists found");
+}
+
+void CDiscDirectoryHelper::UseGroupsWithMultiplesMethod(
+    unsigned int episodeIndex, const std::vector<CVideoInfoTag>& episodesOnDisc)
+{
+  // No groups of numEpisodes length so see if there could be double episode playlists
+  // Assume more than one playlist
+  CLog::LogF(LOGDEBUG, "Using groups with multiples method");
+  for (auto& group : GetGroupsWithoutDuplicates(m_allGroups))
+  {
+    // Calculate multiples
+    // Find shortest playlist in group
+    const std::chrono::milliseconds shortest = std::ranges::min(
+        group | std::views::transform(&CandidatePlaylistInformation::duration), {});
+
+    // Then calculate the average of shortest (within 20% of the shortest) playlists
+    constexpr double SHORTEST_PERCENT{20.0};
+    auto groupDurations{
+        group | std::views::transform(&CandidatePlaylistInformation::duration) |
+        std::views::filter([shortest](const std::chrono::milliseconds i)
+                           { return i < shortest * (1 + (SHORTEST_PERCENT / 100.0)); })};
+    const std::chrono::milliseconds averageShortest{
+        std::accumulate(groupDurations.begin(), groupDurations.end(), 0ms) /
+        std::ranges::distance(groupDurations)};
+
+    // Multiples of average shortest playlists (within 15% of the average)
+    constexpr double MULTIPLE_PERCENT{15.0};
+    for (auto& playlist : group)
+    {
+      double multiple{static_cast<double>(playlist.duration.count()) /
+                      static_cast<double>(averageShortest.count())};
+      int integerMultiple{static_cast<int>(std::round(multiple))};
+
+      // .multiple defaults to 0 if not within MULTIPLE_PERCENT
+      if (abs(multiple - integerMultiple) < MULTIPLE_PERCENT / 100.0)
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: Copilot
body: Ensure that the CandidatePlaylistInformation structure includes a declaration for 'multiple', as the code in this block relies on this field for calculating and storing multiples.
created_at: 2025-05-06T20:25:15Z
author_association: NONE
diff_hunk: @@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
       m_groups.clear();
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: neo1973
body: ```suggestion
```
created_at: 2025-05-08T22:59:32Z
author_association: MEMBER
diff_hunk: @@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
       m_groups.clear();
-      const auto keys{std::views::keys(longPlaylists)};
-      m_groups.emplace_back(keys.begin(), keys.end());
+      for (const auto& PlaylistInformation : longPlaylists)
+      {
+        const auto& [playlist, playlistInformation] = PlaylistInformation;
+        CandidatePlaylistInformation groupPlaylist{.playlist = playlist,
+                                                   .duration = playlistInformation.duration,
+                                                   .clips = {},
+                                                   .languages = {}};
+        m_groups.emplace_back(1, groupPlaylist);
+      }
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: neo1973
body: You can consider using a transform instead of a loop:
```cpp
      std::ranges::transform(
          longPlaylists, std::back_inserter(m_groups),
          [](const auto& PlaylistInformation) -> std::vector<CandidatePlaylistInformation>
          {
            const auto& [playlist, playlistInformation] = PlaylistInformation;
            return {{.playlist = playlist,
                     .duration = playlistInformation.duration,
                     .clips = {},
                     .languages = {}}};
          });
```
But there is definitely not a requirement to do that.
created_at: 2025-05-08T23:19:48Z
author_association: MEMBER
diff_hunk: @@ -384,40 +398,163 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: neo1973
body: ```suggestion
    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroups.emplace_back()),
                         CandidatePlaylistInformationNotDuplicate(seenDurations));
```
created_at: 2025-05-08T23:25:10Z
author_association: MEMBER
diff_hunk: @@ -384,40 +398,163 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
+  }
+  return uniqueGroups;
+}
+
+void CDiscDirectoryHelper::GetPlaylistsFromGroup(
+    unsigned int episodeIndex, const std::vector<CandidatePlaylistInformation>& group)
+{
+  for (unsigned int i = 0; i < m_numEpisodes; ++i)
+  {
+    if (m_allEpisodes != AllEpisodes::ALL &&
+        i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      continue;
+
+    m_candidatePlaylists.emplace(group[i].playlist,
+                                 CandidatePlaylistInformation{.playlist = group[i].playlist,
+                                                              .index = i + m_numSpecials,
+                                                              .duration = group[i].duration,
+                                                              .clips = group[i].clips,
+                                                              .languages = group[i].languages});
+    CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i].playlist);
+  }
+}
+
+void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex,
+                                          const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Method 2a - More than one episode on disc
 
   // Use groups and find nth playlist (or all for all episodes) in group
   // Groups are already contain at least numEpisodes playlists of minimum duration
-  for (const auto& group : m_groups)
+  // Firstly look just at groups that contain exactly numEpisodes playlists
+  CLog::LogF(LOGDEBUG, "Using group method - exact number of playlists");
+  std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: neo1973
body: ```suggestion
  const std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
```
created_at: 2025-05-08T23:46:09Z
author_association: MEMBER
diff_hunk: @@ -139,12 +139,27 @@ class CDiscDirectoryHelper
     std::vector<unsigned int> clips;
     std::string languages;
 
+    // Used for inserting into a set where playlist is the key
     bool operator<(const CandidatePlaylistInformation& rhs) const noexcept
     {
       return playlist < rhs.playlist;
     }
   };
 
+  // For removing duplicates from a vector of CandidatePlaylistInformation
+  struct CandidatePlaylistInformationNotDuplicate
+  {
+    explicit CandidatePlaylistInformationNotDuplicate(std::set<int64_t>& seen) : seen_(seen) {}
+
+    bool operator()(const CandidatePlaylistInformation& c) const noexcept
+    {
+      return seen_.insert(c.duration.count()).second;
+    }
+
+  private:
+    std::set<int64_t>& seen_;
path: xbmc/filesystem/DiscDirectoryHelper.h
user: login: neo1973
body: ```suggestion
    std::set<int64_t>& m_seen;
```
created_at: 2025-05-08T23:57:30Z
author_association: MEMBER
diff_hunk: @@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
       m_groups.clear();
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: 78andyp
body: Thanks, done.
created_at: 2025-05-09T13:18:05Z
author_association: MEMBER
diff_hunk: @@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
       m_groups.clear();
-      const auto keys{std::views::keys(longPlaylists)};
-      m_groups.emplace_back(keys.begin(), keys.end());
+      for (const auto& PlaylistInformation : longPlaylists)
+      {
+        const auto& [playlist, playlistInformation] = PlaylistInformation;
+        CandidatePlaylistInformation groupPlaylist{.playlist = playlist,
+                                                   .duration = playlistInformation.duration,
+                                                   .clips = {},
+                                                   .languages = {}};
+        m_groups.emplace_back(1, groupPlaylist);
+      }
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: 78andyp
body: Interesting, thanks.
Is there any guidance on when to use more complicated std:: routines v. range based fors etc.. 
Is it just down to readability or does the complier optimise them differently??
created_at: 2025-05-09T13:21:12Z
author_association: MEMBER
diff_hunk: @@ -384,40 +398,163 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
+  }
+  return uniqueGroups;
+}
+
+void CDiscDirectoryHelper::GetPlaylistsFromGroup(
+    unsigned int episodeIndex, const std::vector<CandidatePlaylistInformation>& group)
+{
+  for (unsigned int i = 0; i < m_numEpisodes; ++i)
+  {
+    if (m_allEpisodes != AllEpisodes::ALL &&
+        i != episodeIndex - m_numSpecials) // Specials before episodes in episodesOnDisc
+      continue;
+
+    m_candidatePlaylists.emplace(group[i].playlist,
+                                 CandidatePlaylistInformation{.playlist = group[i].playlist,
+                                                              .index = i + m_numSpecials,
+                                                              .duration = group[i].duration,
+                                                              .clips = group[i].clips,
+                                                              .languages = group[i].languages});
+    CLog::LogF(LOGDEBUG, "Candidate playlist {}", group[i].playlist);
+  }
+}
+
+void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex,
+                                          const std::vector<CVideoInfoTag>& episodesOnDisc)
 {
   // Method 2a - More than one episode on disc
 
   // Use groups and find nth playlist (or all for all episodes) in group
   // Groups are already contain at least numEpisodes playlists of minimum duration
-  for (const auto& group : m_groups)
+  // Firstly look just at groups that contain exactly numEpisodes playlists
+  CLog::LogF(LOGDEBUG, "Using group method - exact number of playlists");
+  std::vector groups{GetGroupsWithoutDuplicates(m_groups)};
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: 78andyp
body: Done
created_at: 2025-05-09T13:28:15Z
author_association: MEMBER
diff_hunk: @@ -384,40 +398,163 @@ void CDiscDirectoryHelper::UseLongOrCommonMethodForSingleEpisode(unsigned int ep
   }
 }
 
-void CDiscDirectoryHelper::UseGroupMethod(unsigned int episodeIndex, const PlaylistMap& playlists)
+std::vector<std::vector<CDiscDirectoryHelper::CandidatePlaylistInformation>> CDiscDirectoryHelper::
+    GetGroupsWithoutDuplicates(const std::vector<std::vector<CandidatePlaylistInformation>>& groups)
+{
+  std::vector<std::vector<CandidatePlaylistInformation>> uniqueGroups;
+  for (const auto& playlistGroup : groups)
+  {
+    std::set<int64_t> seenDurations;
+    std::vector<CandidatePlaylistInformation> uniqueGroup;
+    std::ranges::copy_if(playlistGroup, std::back_inserter(uniqueGroup),
+                         CandidatePlaylistInformationNotDuplicate(seenDurations));
+    uniqueGroups.emplace_back(std::move(uniqueGroup));
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: 78andyp
body: Done
created_at: 2025-05-09T13:28:22Z
author_association: MEMBER
diff_hunk: @@ -139,12 +139,27 @@ class CDiscDirectoryHelper
     std::vector<unsigned int> clips;
     std::string languages;
 
+    // Used for inserting into a set where playlist is the key
     bool operator<(const CandidatePlaylistInformation& rhs) const noexcept
     {
       return playlist < rhs.playlist;
     }
   };
 
+  // For removing duplicates from a vector of CandidatePlaylistInformation
+  struct CandidatePlaylistInformationNotDuplicate
+  {
+    explicit CandidatePlaylistInformationNotDuplicate(std::set<int64_t>& seen) : seen_(seen) {}
+
+    bool operator()(const CandidatePlaylistInformation& c) const noexcept
+    {
+      return seen_.insert(c.duration.count()).second;
+    }
+
+  private:
+    std::set<int64_t>& seen_;
path: xbmc/filesystem/DiscDirectoryHelper.h
user: login: 78andyp
body: Done
created_at: 2025-05-09T13:30:08Z
author_association: MEMBER
diff_hunk: @@ -226,36 +240,38 @@ void CDiscDirectoryHelper::FindGroups(const PlaylistMap& playlists)
                                                  .duration = playlistInformation.duration,
                                                  .clips = {},
                                                  .languages = {}};
-      if (!m_groups.empty() && m_groups.back().back() == playlist - 1)
-      {
-        m_groups.back().emplace_back(playlist);
-        m_allGroups.back().emplace_back(groupPlaylist);
-      }
+      if (!m_groups.empty() && m_groups.back().back().playlist == playlist - 1)
+        m_groups.back().emplace_back(groupPlaylist);
       else
-      {
-        // New group
-        m_groups.emplace_back(std::vector{playlist});
-        m_allGroups.emplace_back(std::vector{groupPlaylist});
-      }
+        m_groups.emplace_back(std::vector{groupPlaylist}); // New group
     }
+    m_allGroups = m_groups;
 
     // See if any groups have at least numEpisode playlists and there are exactly
     // numEpisode playlists remaining and no specials, in which case make a group.
     // Assumption has to be playlists match episodes in ascending order
-    if (std::erase_if(m_groups, [this](const std::vector<unsigned int>& group)
-                      { return group.size() < m_numEpisodes; }) == 0 &&
-        m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
+    std::erase_if(m_groups, [this](const std::vector<CandidatePlaylistInformation>& group)
+                  { return group.size() < m_numEpisodes; });
+    if (m_groups.empty() && m_numSpecials == 0 && longPlaylists.size() == m_numEpisodes)
     {
       m_groups.clear();
-      const auto keys{std::views::keys(longPlaylists)};
-      m_groups.emplace_back(keys.begin(), keys.end());
+      for (const auto& PlaylistInformation : longPlaylists)
+      {
+        const auto& [playlist, playlistInformation] = PlaylistInformation;
+        CandidatePlaylistInformation groupPlaylist{.playlist = playlist,
+                                                   .duration = playlistInformation.duration,
+                                                   .clips = {},
+                                                   .languages = {}};
+        m_groups.emplace_back(1, groupPlaylist);
+      }
path: xbmc/filesystem/DiscDirectoryHelper.cpp
user: login: neo1973
body: The main advantage of using algorithms over raw loops is that you can better express your intent. It also helps to break up complex algorithms into parts that are easier to understand. Sean Parents' [C++ Seasonings](https://www.youtube.com/watch?v=W2tWOdzgXHA) talk has been very influential in promoting this style of programming (only the first ~30 min are about this topic).

On the generated code it usually doesn't have a significant influence.
created_at: 2025-05-10T10:42:28Z
author_association: MEMBER

PR comments:
user: login: CrystalP
created_at: 2025-05-07T00:23:25Z
body: I'd suggest writing unit tests to cover the different Bluray layout styles and to avoid regressions.
and if not unit-testable to rework the code so that it is. You have the benefit of writing new code.
author_association: CONTRIBUTOR
user: login: 78andyp
created_at: 2025-05-07T16:23:24Z
body: > I'd suggest writing unit tests to cover the different Bluray layout styles and to avoid regressions. and if not unit-testable to rework the code so that it is. You have the benefit of writing new code.

Will do. Probably not for this PR as I want to make sure the implementation of CDiscDirectoryHelper doesn't change as the tests will be complicated as built on that. I don't see why it would do for movie versions (which is when the playlist matching will be complete) but will add tests then.
author_association: MEMBER
user: login: 78andyp
created_at: 2025-05-08T18:41:22Z
body: Jenkins build this please
author_association: MEMBER