mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: add more randomness to filler picker (#1746)
Rework the filler picker to add more randomness and better weighting behavior. Specifically: 1. Shuffle incoming filler lists 2. Filler weights only added to sampling if they have a program that can fit in the requested time slot 3. Once a list is chosen, we shuffle the programs within that list 4. The rest of the algorithm remains the same with weighted sampling of programs with slight adjustments based on last-seen and duration.
This commit is contained in:
committed by
GitHub
parent
18dd66cb6e
commit
3af479008b
@@ -265,64 +265,102 @@ describe('FillerPickerV2', () => {
|
||||
expect(result.filler).toBeNull();
|
||||
});
|
||||
|
||||
it('accumulates weight across all fillers regardless of cooldown', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// First filler in cooldown
|
||||
const filler1 = createFiller({ weight: 50, cooldown: 60 }); // 1 minute in seconds
|
||||
// Second filler not in cooldown
|
||||
it('accumulates weight across eligible fillers for reservoir sampling', async () => {
|
||||
// Both fillers are eligible (not on cooldown). The reservoir sampling
|
||||
// loop should accumulate weights across all eligible lists.
|
||||
const filler1 = createFiller({ weight: 50, cooldown: 0 });
|
||||
const filler2 = createFiller({ weight: 50, cooldown: 0 });
|
||||
|
||||
// Return history that puts filler1 in cooldown
|
||||
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([
|
||||
createPlayHistory(v4(), new Date(now - 30000), filler1.fillerShowUuid), // In cooldown
|
||||
]);
|
||||
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([]);
|
||||
|
||||
// Track the weight values passed to random.bool
|
||||
const boolCalls: Array<{ weight: number; totalWeight: number }> = [];
|
||||
|
||||
let programCalls = 0,
|
||||
fillerCalls = 0;
|
||||
vi.spyOn(picker, 'weightedPick').mockImplementation(
|
||||
(reason, num, den) => {
|
||||
if (reason === 'filler') {
|
||||
fillerCalls++;
|
||||
boolCalls.push({
|
||||
weight: num,
|
||||
totalWeight: den,
|
||||
});
|
||||
return true;
|
||||
} else if (reason === 'program') {
|
||||
programCalls++;
|
||||
return programCalls > 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
await picker.pickFiller(mockChannel, [filler1, filler2], 60000, now);
|
||||
await picker.pickFiller(mockChannel, [filler1, filler2], 60000);
|
||||
|
||||
// The second filler should see accumulated weight from first filler
|
||||
// filler1 adds 50 to listWeight, then filler2 adds 50 more = 100 total
|
||||
// Both fillers are eligible, so both should get weighted picks.
|
||||
// filler1: weight=50, totalWeight=50
|
||||
// filler2: weight=50, totalWeight=100
|
||||
expect(boolCalls).toHaveLength(2);
|
||||
const listSelectionCall = boolCalls.find((c) => c.totalWeight === 100);
|
||||
expect(listSelectionCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('breaks out of loop after selecting a filler list', async () => {
|
||||
it('skips list with all programs on cooldown and picks from another list', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// List A: single program on repeat cooldown
|
||||
const programA = createProgram({ duration: 10000 });
|
||||
const fillerA = createFiller({ weight: 100 });
|
||||
fillerA.fillerContent = [programA];
|
||||
|
||||
// List B: single program NOT on cooldown (never played)
|
||||
const programB = createProgram({ duration: 10000 });
|
||||
const fillerB = createFiller({ weight: 100 });
|
||||
fillerB.fillerContent = [programB];
|
||||
|
||||
// programA was played 10 minutes ago (within 30-min default cooldown)
|
||||
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([
|
||||
createPlayHistory(
|
||||
programA.uuid,
|
||||
new Date(now - 10 * 60 * 1000),
|
||||
fillerA.fillerShowUuid,
|
||||
),
|
||||
]);
|
||||
|
||||
vi.mocked(random.bool).mockReturnValue(true);
|
||||
|
||||
const result = await picker.pickFiller(
|
||||
mockChannel,
|
||||
[fillerA, fillerB],
|
||||
60000,
|
||||
now,
|
||||
);
|
||||
|
||||
// List A should be skipped (all programs on cooldown).
|
||||
// List B should be picked instead — not null.
|
||||
expect(result.filler).not.toBeNull();
|
||||
expect(result.fillerListId).toBe(fillerB.fillerShowUuid);
|
||||
expect(result.filler?.uuid).toBe(programB.uuid);
|
||||
});
|
||||
|
||||
it('considers all eligible filler lists via reservoir sampling', async () => {
|
||||
const filler1 = createFiller({ weight: 100 });
|
||||
const filler2 = createFiller({ weight: 100 });
|
||||
|
||||
let boolCallCount = 0;
|
||||
vi.mocked(random.bool).mockImplementation(() => {
|
||||
boolCallCount++;
|
||||
return true; // Always select
|
||||
let fillerCalls = 0;
|
||||
let programCalls = 0;
|
||||
vi.spyOn(picker, 'weightedPick').mockImplementation((reason) => {
|
||||
if (reason === 'filler') {
|
||||
fillerCalls++;
|
||||
return true;
|
||||
} else if (reason === 'program') {
|
||||
programCalls++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await picker.pickFiller(mockChannel, [filler1, filler2], 60000);
|
||||
|
||||
// Should break after first filler is selected
|
||||
// One call for list selection, one for program selection
|
||||
expect(boolCallCount).toBeLessThanOrEqual(2);
|
||||
// Both filler lists should be considered (reservoir sampling),
|
||||
// then one program pick from the selected list.
|
||||
expect(fillerCalls).toBe(2);
|
||||
expect(programCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,13 +543,13 @@ describe('FillerPickerV2', () => {
|
||||
const filler = createFiller();
|
||||
filler.fillerContent = [program1, program2];
|
||||
|
||||
// With current implementation, list selection and program selection happen together.
|
||||
// First random.bool selects the list and the first program in one pass.
|
||||
// random.bool returning true selects the list (phase 1), then the
|
||||
// first eligible program from the shuffled list (phase 2).
|
||||
vi.mocked(random.bool).mockReturnValue(true);
|
||||
|
||||
const result = await picker.pickFiller(mockChannel, [filler], 60000);
|
||||
|
||||
// Should pick the first program when random.bool returns true
|
||||
// Should pick the first program (shuffle is mocked as identity)
|
||||
expect(result.filler).not.toBeNull();
|
||||
expect(result.filler?.uuid).toBe(program1.uuid);
|
||||
});
|
||||
@@ -962,8 +1000,8 @@ describe('FillerPickerV2', () => {
|
||||
|
||||
const weightCalls: Array<{ weight: number; total: number }> = [];
|
||||
|
||||
// In current implementation, list selection and program selection happen
|
||||
// in the same random.bool call sequence. First call picks list and first program.
|
||||
// List selection (phase 1) and program selection (phase 2) are separate
|
||||
// passes. Both use weightedPick which delegates to random.bool.
|
||||
vi.mocked(random.bool).mockImplementation((weight, totalWeight) => {
|
||||
weightCalls.push({
|
||||
weight: weight as number,
|
||||
|
||||
@@ -54,8 +54,6 @@ export class FillerPickerV2 implements IFillerPicker {
|
||||
(history) => history.fillerListId,
|
||||
);
|
||||
|
||||
let listWeight = 0;
|
||||
let pickedFiller: Maybe<ChannelFillerShowWithContent>;
|
||||
let minimumWait = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (this.logger.isLevelEnabled('debug')) {
|
||||
@@ -68,104 +66,157 @@ export class FillerPickerV2 implements IFillerPicker {
|
||||
);
|
||||
}
|
||||
|
||||
for (const filler of fillers) {
|
||||
const { weight, cooldown, fillerShow, fillerContent } = filler;
|
||||
// Phase 1: Select a filler list using weighted reservoir sampling.
|
||||
// Shuffle first so that lists with equal weights don't always resolve
|
||||
// in the same DB-insertion order.
|
||||
const shuffledFillers = random.shuffle([...fillers]);
|
||||
let listWeight = 0;
|
||||
let pickedFiller: Maybe<ChannelFillerShowWithContent>;
|
||||
|
||||
for (const filler of shuffledFillers) {
|
||||
const { weight, cooldown, fillerShow } = filler;
|
||||
const fillerHistory = fillerPlayHistoryById[fillerShow.uuid];
|
||||
filler.fillerContent = random.shuffle(filler.fillerContent);
|
||||
const lastPlay = maxBy(fillerHistory, (history) =>
|
||||
dayjs(history.playedAt).valueOf(),
|
||||
);
|
||||
const timeSincePlayedFiller = lastPlay
|
||||
? now - dayjs(lastPlay.playedAt).valueOf()
|
||||
: OneDayMillis;
|
||||
const fillerCooldownMs = cooldown * 1000;
|
||||
|
||||
let programTotalWeight = 0;
|
||||
for (const program of fillerContent) {
|
||||
if (program.duration > maxDuration) {
|
||||
this.logger.trace(
|
||||
'Skipping program %s (%s) from filler list %s because it is too long (%d > %d)',
|
||||
program.uuid,
|
||||
program.title,
|
||||
fillerShow.uuid,
|
||||
program.duration,
|
||||
maxDuration,
|
||||
if (timeSincePlayedFiller >= fillerCooldownMs) {
|
||||
// Check whether this list has at least one program that fits
|
||||
// maxDuration and is past its repeat cooldown. Only lists with
|
||||
// eligible programs participate in reservoir sampling — otherwise
|
||||
// we'd risk picking a list and then failing to find a program.
|
||||
let hasEligibleProgram = false;
|
||||
for (const program of filler.fillerContent) {
|
||||
if (program.duration > maxDuration) continue;
|
||||
const programLastPlayed = fillerHistory?.find(
|
||||
(h) => h.programUuid === program.uuid,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const programLastPlayed = fillerHistory?.find(
|
||||
(h) => h.programUuid === program.uuid,
|
||||
);
|
||||
const timeSincePlayed = programLastPlayed
|
||||
? now - dayjs(programLastPlayed.playedAt).valueOf()
|
||||
: OneDayMillis;
|
||||
|
||||
// Channel level cooldown in effect for this program
|
||||
if (timeSincePlayed < fillerRepeatCooldownMs) {
|
||||
this.logger.trace(
|
||||
'Skipping program %s (%s) from filler list %s because cooldown is in effect (%d < %d)',
|
||||
program.uuid,
|
||||
program.title,
|
||||
fillerShow.uuid,
|
||||
timeSincePlayed,
|
||||
fillerRepeatCooldownMs,
|
||||
);
|
||||
const timeUntilProgramCanPlay =
|
||||
fillerRepeatCooldownMs - timeSincePlayed;
|
||||
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
|
||||
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
|
||||
this.logger.trace('New minimumWait: %d', minimumWait);
|
||||
}
|
||||
} else if (!pickedFiller) {
|
||||
// Need to see if we can even use this list.
|
||||
const fillerHistory = fillerPlayHistoryById[fillerShow.uuid];
|
||||
const lastPlay = maxBy(fillerHistory, (history) =>
|
||||
dayjs(history.playedAt).valueOf(),
|
||||
);
|
||||
const timeSincePlayedFiller = lastPlay
|
||||
? now - dayjs(lastPlay.playedAt).valueOf()
|
||||
const timeSincePlayed = programLastPlayed
|
||||
? now - dayjs(programLastPlayed.playedAt).valueOf()
|
||||
: OneDayMillis;
|
||||
// Weights always count, despite cooldowns.
|
||||
listWeight += weight;
|
||||
const fillerCooldownMs = cooldown * 1000;
|
||||
if (timeSincePlayedFiller >= fillerCooldownMs) {
|
||||
if (this.weightedPick('filler', weight, listWeight)) {
|
||||
pickedFiller = filler;
|
||||
} else {
|
||||
// Didn't pick this filler list based on weight
|
||||
break;
|
||||
}
|
||||
if (timeSincePlayed >= fillerRepeatCooldownMs) {
|
||||
hasEligibleProgram = true;
|
||||
} else {
|
||||
this.logger.trace(
|
||||
'Cannot pick filler list %s (%s) because cooldown is in effect (%d < %d), last played at %s',
|
||||
filler.fillerShowUuid,
|
||||
filler.fillerShow.name,
|
||||
timeSincePlayedFiller,
|
||||
fillerCooldownMs,
|
||||
lastPlay?.playedAt ? dayjs(lastPlay.playedAt).format() : 'never',
|
||||
);
|
||||
const timeUntilListIsCandidate =
|
||||
fillerCooldownMs - timeSincePlayedFiller;
|
||||
if (program.duration + timeUntilListIsCandidate <= maxDuration) {
|
||||
minimumWait = Math.min(
|
||||
minimumWait,
|
||||
program.duration + timeUntilListIsCandidate,
|
||||
);
|
||||
const timeUntilProgramCanPlay =
|
||||
fillerRepeatCooldownMs - timeSincePlayed;
|
||||
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
|
||||
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
|
||||
this.logger.trace('New minimumWait: %d', minimumWait);
|
||||
}
|
||||
// Cannot use this list because cooldown is in effect
|
||||
break;
|
||||
}
|
||||
|
||||
const normalizedSince = normalizeSince(
|
||||
timeSincePlayed >= FiveMinutesMillis
|
||||
? FiveMinutesMillis
|
||||
: timeSincePlayed,
|
||||
);
|
||||
const normalizedDuration = normalizeDuration(program.duration);
|
||||
const programWeight = normalizedSince + normalizedDuration;
|
||||
programTotalWeight += programWeight;
|
||||
if (this.weightedPick('program', programWeight, programTotalWeight)) {
|
||||
return {
|
||||
filler: program,
|
||||
fillerListId: pickedFiller.fillerShowUuid,
|
||||
minimumWait: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEligibleProgram) {
|
||||
listWeight += weight;
|
||||
if (this.weightedPick('filler', weight, listWeight)) {
|
||||
pickedFiller = filler;
|
||||
}
|
||||
} else {
|
||||
this.logger.trace(
|
||||
'Skipping filler list %s (%s) — no eligible programs (all on cooldown or too long)',
|
||||
filler.fillerShowUuid,
|
||||
filler.fillerShow.name,
|
||||
);
|
||||
}
|
||||
// Continue iterating — reservoir sampling requires seeing all candidates.
|
||||
} else {
|
||||
this.logger.trace(
|
||||
'Cannot pick filler list %s (%s) because cooldown is in effect (%d < %d), last played at %s',
|
||||
filler.fillerShowUuid,
|
||||
filler.fillerShow.name,
|
||||
timeSincePlayedFiller,
|
||||
fillerCooldownMs,
|
||||
lastPlay?.playedAt ? dayjs(lastPlay.playedAt).format() : 'never',
|
||||
);
|
||||
// Track minimumWait for the list cooldown.
|
||||
const timeUntilListIsCandidate =
|
||||
fillerCooldownMs - timeSincePlayedFiller;
|
||||
const shortestProgram = filler.fillerContent.reduce(
|
||||
(min, p) => Math.min(min, p.duration),
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
if (shortestProgram + timeUntilListIsCandidate <= maxDuration) {
|
||||
minimumWait = Math.min(
|
||||
minimumWait,
|
||||
shortestProgram + timeUntilListIsCandidate,
|
||||
);
|
||||
this.logger.trace('New minimumWait: %d', minimumWait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pickedFiller) {
|
||||
return {
|
||||
filler: null,
|
||||
fillerListId: null,
|
||||
minimumWait: minimumWait < 0 ? 15_000 : minimumWait,
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: Select a program from the picked list using weighted
|
||||
// reservoir sampling. Shuffle so that programs with equal weights
|
||||
// don't always resolve in the same order.
|
||||
const fillerHistory = fillerPlayHistoryById[pickedFiller.fillerShow.uuid];
|
||||
const shuffledPrograms = random.shuffle([...pickedFiller.fillerContent]);
|
||||
let programTotalWeight = 0;
|
||||
|
||||
for (const program of shuffledPrograms) {
|
||||
if (program.duration > maxDuration) {
|
||||
this.logger.trace(
|
||||
'Skipping program %s (%s) from filler list %s because it is too long (%d > %d)',
|
||||
program.uuid,
|
||||
program.title,
|
||||
pickedFiller.fillerShow.uuid,
|
||||
program.duration,
|
||||
maxDuration,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const programLastPlayed = fillerHistory?.find(
|
||||
(h) => h.programUuid === program.uuid,
|
||||
);
|
||||
const timeSincePlayed = programLastPlayed
|
||||
? now - dayjs(programLastPlayed.playedAt).valueOf()
|
||||
: OneDayMillis;
|
||||
|
||||
// Channel level cooldown in effect for this program
|
||||
if (timeSincePlayed < fillerRepeatCooldownMs) {
|
||||
this.logger.trace(
|
||||
'Skipping program %s (%s) from filler list %s because cooldown is in effect (%d < %d)',
|
||||
program.uuid,
|
||||
program.title,
|
||||
pickedFiller.fillerShow.uuid,
|
||||
timeSincePlayed,
|
||||
fillerRepeatCooldownMs,
|
||||
);
|
||||
const timeUntilProgramCanPlay =
|
||||
fillerRepeatCooldownMs - timeSincePlayed;
|
||||
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
|
||||
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
|
||||
this.logger.trace('New minimumWait: %d', minimumWait);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedSince = normalizeSince(
|
||||
timeSincePlayed >= FiveMinutesMillis
|
||||
? FiveMinutesMillis
|
||||
: timeSincePlayed,
|
||||
);
|
||||
const normalizedDuration = normalizeDuration(program.duration);
|
||||
const programWeight = normalizedSince + normalizedDuration;
|
||||
programTotalWeight += programWeight;
|
||||
if (this.weightedPick('program', programWeight, programTotalWeight)) {
|
||||
return {
|
||||
filler: program,
|
||||
fillerListId: pickedFiller.fillerShowUuid,
|
||||
minimumWait: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user