Handle release stats for multi-per-day podcasts (#7755)

This commit is contained in:
tmatale 2025-08-20 14:23:55 -04:00 committed by GitHub
parent e539479f2c
commit c8cd0de157
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 179 additions and 47 deletions

View File

@ -6,7 +6,9 @@ import java.util.Calendar;
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 @@ import java.util.List;
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 class ReleaseScheduleGuesser {
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 @@ public class ReleaseScheduleGuesser {
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 @@ public class ReleaseScheduleGuesser {
daysOfWeek, daysOfMonth, mostOftenDayOfWeek, mostOftenDayOfMonth);
}
public static Guess performGuess(List<Date> releaseDates) {
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());
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, 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 class ReleaseScheduleGuesser {
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 class ReleaseScheduleGuesser {
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 class ReleaseScheduleGuesser {
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 class ReleaseScheduleGuesser {
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 class ReleaseScheduleGuesser {
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);
}
}
}

View File

@ -40,6 +40,71 @@ public class ReleaseScheduleGuesserTest {
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 class ReleaseScheduleGuesserTest {
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);
}
}

View File

@ -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>

View File

@ -122,19 +122,22 @@ public class FeedStatisticsFragment extends Fragment {
}
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 @@ public class FeedStatisticsFragment extends Fragment {
}
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 @@ public class FeedStatisticsFragment extends Fragment {
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));