Repository: AntennaPod/AntennaPod (2025-04-13T04:00:18Z)

Issue:
Release schedule prediction not working for podcasts that release multiple times a day
### Checklist

- [x] I have used the search function for [**OPEN**](https://github.com/AntennaPod/AntennaPod/issues) issues to see if someone else has already submitted the same bug report.
- [x] I have **also** used the search function for [**CLOSED**](https://github.com/AntennaPod/AntennaPod/issues?q=is%3Aissue+is%3Aclosed) issues to see if the problem is already solved and just waiting to be released.
- [x] I will describe the problem with as much detail as possible.
- [x] If the bug only to occurs with a certain podcast, I will include the URL of that podcast.

### App version

3.6.1

### Android version

LOS 22.1

### Device model

guacamole

### First occurred

Only noticed it today

### Steps to reproduce

1. Subscribe to a podcast that releases multiple times a day, 7 days a week
2. Let AP work its magic
3. Check podcasts stats

If you need specific podcast for testing, any frequent release news podcast should work. NPR, CNN, Al Jazeera, BBC should all have some that fits the bill, or pick one you like better.

### Expected behaviour

Podcasts that release multiple times a day on a regular basis should be identified as such.

### Current behaviour

Podcasts that release multiples times a day are identified following an odd schedule, such as "Wed, Thu, Fri" or "Wed, Thu, Fri, Sat", even though they push episodes out 7 days a week.

### Logs

_No response_


Discussion:
I'd like to fix this one if that's alright. Reviewing the problem it looks like [the value here](https://github.com/AntennaPod/AntennaPod/blob/699b96b17473cd7cd9373dfa10892d0044332281/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesser.java#L20) is too low for podcasts that have multi-per-day releases. I used the "Al Jazeera News Update" podcast for testing and when I simply raise this limit to 100, it works correctly. Is there a reason this limit is 20? The full amount is already loaded into memory before trimming it, and I don't think it's likely we would exceed the int maximum value. Is the concern that calculating a lot of episodes would be too slow or we want to reflect changes in schedule more accurately by limiting to 20?

My thoughts are we should change this to only calculate the stats based on the last 60 or 90 days of episodes rather than a cap of 20 episodes back. This should fix this issue and also having a moving window of 2 to 3 months that will auto-update in case schedules change.
> or we want to reflect changes in schedule more accurately by limiting to 20?

Yeah, that was the idea. Maybe we should just ignore multiple episodes a day when we decide how many we want to take into account? Or maybe we could also specifically add support for multiple episodes a day and even try to predict the time? Then, how do we represent that if there are multiple episodes but only on weekdays?

If you want to play around with it, we have a test called `ReleaseScheduleGuesserRealWorldTest` that runs on a quite long list of real podcast release schedules. With that test you can check if changing something in the prediction algorithm decreases or increases the quality of the predictions.
How often do podcast even change schedules ? In my experience, most either shut down or keep going.

Those which change schedule either have:

1. Seasons or unstructured breaks/hiatuses.
2. Relatively irregular and/or infrequent schedules so limiting for the absolute number of episodes instead of a time frame gives you a chance to get a significant sample, that OTOH doesn't mean the app would be reacting promptly to changes as the time-span covered by 20 episodes might stretch over many months when not years.

Perhaps if accuracy is paramount, it might be necessary analyzing both the time and frequency domains.

Finally, personal opinion, I would imagine predicting multiple-a-day schedules is not very useful or productive. If the app embraced the schedule prediction paradigm, you could just have a manual switch for users to flip and tell the app to check those podcasts on, say, an hourly basis while others with less frequent releases (e.g. weekly) and have a lower priority on the predictive update assembly line.

I say lower priority because if someone is really a die-hard fan of a podcast (dunno, think true crime types maybe ?) then they would probably keep dragnet updates on for those podcasts on the off-chance there is a special or other out-of-schedule update.
> If the app embraced the schedule prediction paradigm, you could just have a manual switch for users to flip

Average users don't want to go through dozens or even hundreds of subscriptions and set the schedule manually. This needs to be automatic for it to be useful.
> > If the app embraced the schedule prediction paradigm, you could just have a manual switch for users to flip
> 
> Average users don't want to go through dozens or even hundreds of subscriptions and set the schedule manually. This needs to be automatic for it to be useful.

I never said that you should get rid of the automation, I said consider having a manual fail-safe for when the automatic logic fucks up.

The "average user" does not HAVE to go through manual settings, thus your argument contains not just one but at least two different logical fallacies.



Patch:
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesser.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesser.java
index 3ee4e5b1d9..b98fdbd063 100644
--- a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesser.java
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesser.java
@@ -6,7 +6,9 @@
 import java.util.Collections;
 import java.util.Date;
 import java.util.GregorianCalendar;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Can be used to guess the release schedule of podcasts based on a sorted list of past release dates
@@ -14,10 +16,11 @@
 public class ReleaseScheduleGuesser {
     static final long ONE_MINUTE = 60 * 1000;
     static final long ONE_HOUR = ONE_MINUTE * 60;
-    static final long ONE_DAY =  ONE_HOUR * 24;
+    static final long ONE_DAY = ONE_HOUR * 24;
     static final long ONE_WEEK = ONE_DAY * 7;
     static final long ONE_MONTH = ONE_DAY * 30;
-    private static final int MAX_DATA_POINTS = 20;
+    private static final int MAX_UNIQUE_DATES = 20;
+    private static final int MULTIPLE_PER_DAY_THRESHOLD = 2;
 
     public enum Schedule {
         DAILY, WEEKDAYS, SPECIFIC_DAYS,
@@ -29,11 +32,13 @@ public static class Guess {
         public final Schedule schedule;
         public final List<Integer> days;
         public final Date nextExpectedDate;
+        public final boolean multipleReleasesPerDay;
 
-        public Guess(Schedule schedule, List<Integer> days, Date nextExpectedDate) {
+        public Guess(Schedule schedule, List<Integer> days, Date nextExpectedDate, boolean multipleReleasesPerDay) {
             this.schedule = schedule;
             this.days = days;
             this.nextExpectedDate = nextExpectedDate;
+            this.multipleReleasesPerDay = multipleReleasesPerDay;
         }
     }
 
@@ -62,10 +67,10 @@ private static void addTime(GregorianCalendar date, long time) {
         date.setTime(new Date(date.getTime().getTime() + time));
     }
 
-    private static void addUntil(GregorianCalendar date, List<Integer> days) {
+    private static void addTimeUntilOnAllowedDay(GregorianCalendar date, long amount, List<Integer> allowedDays) {
         do {
-            addTime(date, ONE_DAY);
-        } while (!days.contains(date.get(Calendar.DAY_OF_WEEK)));
+            addTime(date, amount);
+        } while (!allowedDays.contains(date.get(Calendar.DAY_OF_WEEK)));
     }
 
     private static <T> T getMedian(List<T> list) {
@@ -122,12 +127,41 @@ private static Stats getStats(List<Date> releaseDates) {
                 daysOfWeek, daysOfMonth, mostOftenDayOfWeek, mostOftenDayOfMonth);
     }
 
+    private static List<Integer> getLargeDays(ReleaseScheduleGuesser.Stats stats, int maxDaysOff) {
+        List<Integer> largeDays = new ArrayList<>();
+        for (int i = Calendar.SUNDAY; i <= Calendar.SATURDAY; i++) {
+            if (stats.daysOfWeek[i] > maxDaysOff) {
+                largeDays.add(i);
+            }
+        }
+        return largeDays;
+    }
+
+    private static Date getNormalizedDate(Date date) {
+        GregorianCalendar current = new GregorianCalendar();
+        current.setTime(date);
+        current.set(Calendar.HOUR_OF_DAY, 0);
+        current.set(Calendar.MINUTE, 0);
+        current.set(Calendar.SECOND, 0);
+        current.set(Calendar.MILLISECOND, 0);
+        return current.getTime();
+    }
+
     public static Guess performGuess(List<Date> releaseDates) {
+        Collections.sort(releaseDates);
+        Set<Date> uniqueDates = new HashSet<>();
+        int releaseDatesLowerIndex = releaseDates.size();
+        while (releaseDatesLowerIndex > 0 && uniqueDates.size() < MAX_UNIQUE_DATES) {
+            Date normalizedDate = getNormalizedDate(releaseDates.get(--releaseDatesLowerIndex));
+            uniqueDates.add(normalizedDate);
+        }
+
         if (releaseDates.size() <= 1) {
-            return new Guess(Schedule.UNKNOWN, null, null);
-        } else if (releaseDates.size() > MAX_DATA_POINTS) {
-            releaseDates = releaseDates.subList(releaseDates.size() - MAX_DATA_POINTS, releaseDates.size());
+            return new Guess(Schedule.UNKNOWN, null, null, false);
+        } else if (releaseDates.size() > MAX_UNIQUE_DATES) {
+            releaseDates = releaseDates.subList(releaseDatesLowerIndex, releaseDates.size());
         }
+
         Stats stats = getStats(releaseDates);
         final int maxTotalWrongDays = Math.max(1, releaseDates.size() / 5);
         final int maxSingleDayOff = releaseDates.size() / 10;
@@ -139,11 +173,41 @@ public static Guess performGuess(List<Date> releaseDates) {
         last.set(Calendar.SECOND, 0);
         last.set(Calendar.MILLISECOND, 0);
 
-        if (Math.abs(stats.medianDistance - ONE_DAY) < 2 * ONE_HOUR
+        boolean multipleReleasesPerDay = (releaseDates.size() - uniqueDates.size()) >= MULTIPLE_PER_DAY_THRESHOLD;
+        if (multipleReleasesPerDay) {
+            float averagePerDayAmount = (float) releaseDates.size() / uniqueDates.size();
+            Date date = getNormalizedDate(last.getTime());
+            int releasesToday = 0;
+            for (Date releaseDate : releaseDates) {
+                if (date.equals(getNormalizedDate(releaseDate))) {
+                    releasesToday++;
+                }
+            }
+
+            long distance;
+            if (releasesToday <= averagePerDayAmount) {
+                distance = (long) stats.medianDistance;
+            } else {
+                distance = ONE_DAY;
+            }
+
+            List<Integer> largeDays = getLargeDays(stats, 0);
+            addTimeUntilOnAllowedDay(last, distance, largeDays);
+            Schedule schedule = Schedule.SPECIFIC_DAYS;
+            if (largeDays.size() == 7) {
+                schedule = Schedule.DAILY;
+            } else  if (largeDays.size() == 5 && largeDays.containsAll(Arrays.asList(
+                    Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY))) {
+                schedule = Schedule.WEEKDAYS;
+            }
+
+            return new Guess(schedule, largeDays, last.getTime(), true);
+        } else if (Math.abs(stats.medianDistance - ONE_DAY) < 2 * ONE_HOUR
                 && stats.avgDeltaToMedianDistance < 2 * ONE_HOUR) {
             addTime(last, ONE_DAY);
             return new Guess(Schedule.DAILY, Arrays.asList(Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY,
-                    Calendar.THURSDAY, Calendar.FRIDAY, Calendar.SATURDAY, Calendar.SUNDAY), last.getTime());
+                    Calendar.THURSDAY, Calendar.FRIDAY, Calendar.SATURDAY, Calendar.SUNDAY), last.getTime(),
+                    false);
         } else if (Math.abs(stats.medianDistance - ONE_WEEK) < ONE_DAY
                 && stats.avgDeltaToMedianDistance < 2 * ONE_DAY) {
             // Just using last.set(Calendar.DAY_OF_WEEK) could skip a week
@@ -152,7 +216,7 @@ public static Guess performGuess(List<Date> releaseDates) {
             do {
                 addTime(last, ONE_DAY);
             } while (last.get(Calendar.DAY_OF_WEEK) != stats.mostOftenDayOfWeek);
-            return new Guess(Schedule.WEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime());
+            return new Guess(Schedule.WEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime(), false);
         } else if (Math.abs(stats.medianDistance - 2 * ONE_WEEK) < ONE_DAY
                 && stats.avgDeltaToMedianDistance < 2 * ONE_DAY) {
             // Just using last.set(Calendar.DAY_OF_WEEK) could skip a week
@@ -161,7 +225,7 @@ public static Guess performGuess(List<Date> releaseDates) {
             do {
                 addTime(last, ONE_DAY);
             } while (last.get(Calendar.DAY_OF_WEEK) != stats.mostOftenDayOfWeek);
-            return new Guess(Schedule.BIWEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime());
+            return new Guess(Schedule.BIWEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime(), false);
         } else if (Math.abs(stats.medianDistance - ONE_MONTH) < 5 * ONE_DAY
                 && stats.avgDeltaToMedianDistance < 5 * ONE_DAY) {
             if (stats.daysOfMonth[stats.mostOftenDayOfMonth] >= releaseDates.size() - maxTotalWrongDays) {
@@ -171,23 +235,19 @@ public static Guess performGuess(List<Date> releaseDates) {
                 do {
                     addTime(last, ONE_DAY);
                 } while (last.get(Calendar.DAY_OF_MONTH) != stats.mostOftenDayOfMonth);
-                return new Guess(Schedule.MONTHLY, null, last.getTime());
+                return new Guess(Schedule.MONTHLY, null, last.getTime(), false);
             }
 
             addTime(last, 3 * ONE_WEEK + 3 * ONE_DAY);
             do {
                 addTime(last, ONE_DAY);
             } while (last.get(Calendar.DAY_OF_WEEK) != stats.mostOftenDayOfWeek);
-            return new Guess(Schedule.FOURWEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime());
+            return new Guess(Schedule.FOURWEEKLY, List.of(stats.mostOftenDayOfWeek), last.getTime(), false);
         }
 
         // Find release days
-        List<Integer> largeDays = new ArrayList<>();
-        for (int i = Calendar.SUNDAY; i <= Calendar.SATURDAY; i++) {
-            if (stats.daysOfWeek[i] > maxSingleDayOff) {
-                largeDays.add(i);
-            }
-        }
+        List<Integer> largeDays = getLargeDays(stats, maxSingleDayOff);
+
         // Ensure that all release days are used similarly often
         int averageDays = releaseDates.size() / largeDays.size();
         boolean matchesAverageDays = true;
@@ -200,20 +260,19 @@ public static Guess performGuess(List<Date> releaseDates) {
 
         if (matchesAverageDays && stats.medianDistance < ONE_WEEK) {
             // Fixed daily release schedule (eg Mo, Thu, Fri)
-            addUntil(last, largeDays);
+            addTimeUntilOnAllowedDay(last, ONE_DAY, largeDays);
 
             if (largeDays.size() == 5 && largeDays.containsAll(Arrays.asList(
                     Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY))) {
-                return new Guess(Schedule.WEEKDAYS, largeDays, last.getTime());
+                return new Guess(Schedule.WEEKDAYS, largeDays, last.getTime(), false);
             }
-            return new Guess(Schedule.SPECIFIC_DAYS, largeDays, last.getTime());
+            return new Guess(Schedule.SPECIFIC_DAYS, largeDays, last.getTime(), false);
         } else if (largeDays.size() == 1) {
             // Probably still weekly with more exceptions than others
-            addUntil(last, largeDays);
-            return new Guess(Schedule.WEEKLY, largeDays, last.getTime());
+            addTimeUntilOnAllowedDay(last, ONE_DAY, largeDays);
+            return new Guess(Schedule.WEEKLY, largeDays, last.getTime(), false);
         }
-
         addTime(last, (long) (0.6f * stats.medianDistance));
-        return new Guess(Schedule.UNKNOWN, null, last.getTime());
+        return new Guess(Schedule.UNKNOWN, null, last.getTime(), false);
     }
-}
+}
\ No newline at end of file
diff --git a/ui/i18n/src/main/res/values/strings.xml b/ui/i18n/src/main/res/values/strings.xml
index 5eb3088e42..2bcacb8e09 100644
--- a/ui/i18n/src/main/res/values/strings.xml
+++ b/ui/i18n/src/main/res/values/strings.xml
@@ -789,6 +789,7 @@
     <string name="statistics_release_schedule">release schedule</string>
     <string name="statistics_release_next">next episode (estimate)</string>
     <string name="statistics_expected_next_episode_any_day">Any day now</string>
+    <string name="statistics_expected_next_episode_any_time">Any time now</string>
     <string name="statistics_expected_next_episode_unknown">Unknown</string>
     <string name="statistics_view_all">All podcasts »</string>
     <string name="statistics_years_barchart_description">Time played per month</string>
@@ -797,6 +798,7 @@
     <string name="edit_url_confirmation_msg">Changing the RSS address can easily break the playback state and episode listings of the podcast. We do NOT recommend changing it and will NOT provide support if anything goes wrong. This cannot be undone. The broken subscription CANNOT be repaired by simply changing the address back. We recommend creating a backup before continuing.</string>
 
     <!-- Podcast release schedules -->
+    <string name="release_schedule_multiple_per_day">multiple times per day</string>
     <string name="release_schedule_daily">daily</string>
     <string name="release_schedule_weekdays">on weekdays</string>
     <string name="release_schedule_weekly">weekly</string>
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java
index 16bf06ed59..4e53e68923 100644
--- a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java
@@ -122,19 +122,22 @@ private String getReadableDay(int day) {
     }
 
     private String getReadableSchedule(ReleaseScheduleGuesser.Guess guess) {
+        String prefix = guess.multipleReleasesPerDay ? getString(R.string.release_schedule_multiple_per_day)
+                + ", " : "";
         switch (guess.schedule) {
             case DAILY:
-                return getString(R.string.release_schedule_daily);
+                return prefix + getString(R.string.release_schedule_daily);
             case WEEKDAYS:
-                return getString(R.string.release_schedule_weekdays);
+                return prefix + getString(R.string.release_schedule_weekdays);
             case WEEKLY:
-                return getString(R.string.release_schedule_weekly) + ", " + getReadableDay(guess.days.get(0));
+                return prefix + getString(R.string.release_schedule_weekly) + ", " + getReadableDay(guess.days.get(0));
             case BIWEEKLY:
-                return getString(R.string.release_schedule_biweekly) + ", " + getReadableDay(guess.days.get(0));
+                return prefix + getString(R.string.release_schedule_biweekly) + ", "
+                        + getReadableDay(guess.days.get(0));
             case MONTHLY:
-                return getString(R.string.release_schedule_monthly);
+                return prefix + getString(R.string.release_schedule_monthly);
             case FOURWEEKLY:
-                return getString(R.string.release_schedule_monthly) + ", " + getReadableDay(guess.days.get(0));
+                return prefix + getString(R.string.release_schedule_monthly) + ", " + getReadableDay(guess.days.get(0));
             case SPECIFIC_DAYS:
                 StringBuilder days = new StringBuilder();
                 for (int i = 0; i < guess.days.size(); i++) {
@@ -143,9 +146,9 @@ private String getReadableSchedule(ReleaseScheduleGuesser.Guess guess) {
                     }
                     days.append(getReadableDay(guess.days.get(i)));
                 }
-                return days.toString();
+                return prefix + days.toString();
             default:
-                return getString(R.string.statistics_expected_next_episode_unknown);
+                return prefix + getString(R.string.statistics_expected_next_episode_unknown);
         }
     }
 
@@ -190,7 +193,10 @@ private void showStats(Pair<StatisticsItem, ReleaseScheduleGuesser.Guess> p) {
             viewBinding.episodeSchedule.mainLabel.setText(R.string.statistics_expected_next_episode_unknown);
         } else {
             if (guess.nextExpectedDate.getTime() <= new Date().getTime()) {
-                viewBinding.expectedNextEpisode.mainLabel.setText(R.string.statistics_expected_next_episode_any_day);
+                viewBinding.expectedNextEpisode.mainLabel.setText(
+                        guess.multipleReleasesPerDay
+                                ? R.string.statistics_expected_next_episode_any_time
+                                : R.string.statistics_expected_next_episode_any_day);
             } else {
                 viewBinding.expectedNextEpisode.mainLabel.setText(
                         DateFormatter.formatAbbrev(getContext(), guess.nextExpectedDate));


Test patch:
diff --git a/storage/database/src/test/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesserTest.java b/storage/database/src/test/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesserTest.java
index 92fcbbea01..61721c0437 100644
--- a/storage/database/src/test/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesserTest.java
+++ b/storage/database/src/test/java/de/danoeh/antennapod/storage/database/ReleaseScheduleGuesserTest.java
@@ -40,6 +40,71 @@ public void testEdgeCases() {
         assertEquals(ReleaseScheduleGuesser.Schedule.UNKNOWN, performGuess(releaseDates).schedule);
     }
 
+    @Test
+    public void testMultipleTimesPerDayEveryDay() {
+        ArrayList<Date> releaseDates = new ArrayList<>();
+        releaseDates.add(makeDate("2024-01-01 12:00"));
+        releaseDates.add(makeDate("2024-01-01 16:00"));
+        releaseDates.add(makeDate("2024-01-02 12:00"));
+        releaseDates.add(makeDate("2024-01-02 16:00"));
+        releaseDates.add(makeDate("2024-01-03 12:00"));
+        releaseDates.add(makeDate("2024-01-03 16:00"));
+        releaseDates.add(makeDate("2024-01-04 12:00"));
+        releaseDates.add(makeDate("2024-01-04 16:00"));
+        releaseDates.add(makeDate("2024-01-06 12:00"));
+        releaseDates.add(makeDate("2024-01-06 16:00"));
+        releaseDates.add(makeDate("2024-01-07 12:00"));
+        releaseDates.add(makeDate("2024-01-07 16:00"));
+        ReleaseScheduleGuesser.Guess guess = performGuess(releaseDates);
+        assertEquals(ReleaseScheduleGuesser.Schedule.SPECIFIC_DAYS, guess.schedule);
+        assertTrue(guess.multipleReleasesPerDay);
+        ArrayList<Integer> expectedDays = new ArrayList<>();
+        expectedDays.add(1);
+        expectedDays.add(2);
+        expectedDays.add(3);
+        expectedDays.add(4);
+        expectedDays.add(5);
+        expectedDays.add(7);
+        assertEquals(expectedDays, guess.days);
+        assertClose(makeDate("2024-01-08 12:00"), guess.nextExpectedDate, ONE_DAY);
+    }
+
+    @Test
+    public void testMultipleTimesPerDayWeekdays() {
+        ArrayList<Date> releaseDates = new ArrayList<>();
+        releaseDates.add(makeDate("2024-01-01 00:00"));
+        releaseDates.add(makeDate("2024-01-01 12:00"));
+        releaseDates.add(makeDate("2024-01-02 00:00"));
+        releaseDates.add(makeDate("2024-01-02 12:00"));
+        releaseDates.add(makeDate("2024-01-03 00:00"));
+        releaseDates.add(makeDate("2024-01-03 12:00"));
+        releaseDates.add(makeDate("2024-01-04 00:00"));
+        releaseDates.add(makeDate("2024-01-04 12:00"));
+        releaseDates.add(makeDate("2024-01-05 00:00"));
+        releaseDates.add(makeDate("2024-01-05 12:00"));
+        ReleaseScheduleGuesser.Guess guess = performGuess(releaseDates);
+        assertEquals(ReleaseScheduleGuesser.Schedule.WEEKDAYS, guess.schedule);
+        assertTrue(guess.multipleReleasesPerDay);
+        assertClose(makeDate("2024-01-08 12:00"), guess.nextExpectedDate, ONE_DAY);
+    }
+
+    @Test
+    public void testMultipleTimesPerDaySpecificDays() {
+        ArrayList<Date> releaseDates = new ArrayList<>();
+        releaseDates.add(makeDate("2024-01-01 00:00"));
+        releaseDates.add(makeDate("2024-01-01 12:00"));
+        releaseDates.add(makeDate("2024-01-03 00:00"));
+        releaseDates.add(makeDate("2024-01-03 12:00"));
+        releaseDates.add(makeDate("2024-01-08 00:00"));
+        releaseDates.add(makeDate("2024-01-08 12:00"));
+        releaseDates.add(makeDate("2024-01-10 00:00"));
+        releaseDates.add(makeDate("2024-01-10 12:00"));
+        ReleaseScheduleGuesser.Guess guess = performGuess(releaseDates);
+        assertEquals(ReleaseScheduleGuesser.Schedule.SPECIFIC_DAYS, guess.schedule);
+        assertTrue(guess.multipleReleasesPerDay);
+        assertClose(makeDate("2024-01-15 12:00"), guess.nextExpectedDate, ONE_DAY);
+    }
+
     @Test
     public void testDaily() {
         ArrayList<Date> releaseDates = new ArrayList<>();
@@ -158,15 +223,15 @@ public void testFourweekly() {
     public void testUnknown() {
         ArrayList<Date> releaseDates = new ArrayList<>();
         releaseDates.add(makeDate("2024-01-01 16:30"));
-        releaseDates.add(makeDate("2024-01-03 16:30"));
-        releaseDates.add(makeDate("2024-01-03 16:31"));
         releaseDates.add(makeDate("2024-01-04 16:30"));
-        releaseDates.add(makeDate("2024-01-04 16:31"));
-        releaseDates.add(makeDate("2024-01-07 16:30"));
-        releaseDates.add(makeDate("2024-01-07 16:31"));
-        releaseDates.add(makeDate("2024-01-10 16:30"));
+        releaseDates.add(makeDate("2024-01-10 16:31"));
+        releaseDates.add(makeDate("2024-01-11 16:30"));
+        releaseDates.add(makeDate("2024-01-20 16:31"));
+        releaseDates.add(makeDate("2024-01-22 16:30"));
+        releaseDates.add(makeDate("2024-01-25 16:31"));
+        releaseDates.add(makeDate("2024-01-25 16:30"));
         ReleaseScheduleGuesser.Guess guess = performGuess(releaseDates);
         assertEquals(ReleaseScheduleGuesser.Schedule.UNKNOWN, guess.schedule);
-        assertClose(makeDate("2024-01-12 16:30"), guess.nextExpectedDate, 2 * ONE_DAY);
+        assertClose(makeDate("2024-01-27 16:30"), guess.nextExpectedDate, 2 * ONE_DAY);
     }
 }
\ No newline at end of file