Skip to content

Commit 5832f46

Browse files
committed
feat(deduplicator): add tiebreakers configuration
Release-As: 2.30.3
1 parent 376a013 commit 5832f46

4 files changed

Lines changed: 153 additions & 29 deletions

File tree

packages/core/src/db/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ const DeduplicatorOptions = z.object({
258258
libraryBehaviour: z
259259
.enum(constants.DEDUPLICATOR_LIBRARY_BEHAVIOURS)
260260
.optional(),
261+
tiebreakers: z
262+
.array(
263+
z.object({
264+
type: z.enum(constants.DEDUPLICATOR_TIEBREAKERS),
265+
position: z.enum(['before_addon', 'after_addon']),
266+
})
267+
)
268+
.optional(),
261269
});
262270

263271
const OptionDefinition = z.looseObject({

packages/core/src/streams/deduplicator.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class StreamDeduplicator {
4242
constants.DEFAULT_SMART_DETECT_ATTRIBUTES,
4343
smartDetectRounding: deduplicator.smartDetectRounding ?? 10,
4444
libraryBehaviour: deduplicator.libraryBehaviour ?? 'ignore',
45+
tiebreakers: deduplicator.tiebreakers ?? [
46+
{ type: 'torrent_seeders' as const, position: 'before_addon' as const },
47+
{ type: 'usenet_age' as const, position: 'before_addon' as const },
48+
],
4549
};
4650

4751
// Group streams by their deduplication keys
@@ -234,6 +238,42 @@ class StreamDeduplicator {
234238
}
235239

236240
// Process each type according to its deduplication mode
241+
const seedersEntry = deduplicator.tiebreakers!.find(
242+
(t) => t.type === 'torrent_seeders'
243+
);
244+
const usenetEntry = deduplicator.tiebreakers!.find(
245+
(t) => t.type === 'usenet_age'
246+
);
247+
248+
const tiebreakerCmp = (
249+
a: ParsedStream,
250+
b: ParsedStream,
251+
type: string,
252+
position: 'before_addon' | 'after_addon' | 'any'
253+
): number => {
254+
if (
255+
seedersEntry &&
256+
(position === 'any' || seedersEntry.position === position) &&
257+
(type === 'p2p' || type === 'uncached') &&
258+
a.torrent?.seeders !== undefined &&
259+
b.torrent?.seeders !== undefined &&
260+
(a.torrent.seeders || 0) !== (b.torrent.seeders || 0)
261+
) {
262+
return (b.torrent.seeders || 0) - (a.torrent.seeders || 0);
263+
}
264+
if (
265+
usenetEntry &&
266+
(position === 'any' || usenetEntry.position === position) &&
267+
(type === 'usenet' || type === 'stremio-usenet') &&
268+
a.age !== undefined &&
269+
b.age !== undefined &&
270+
Math.abs(a.age - b.age) > 24
271+
) {
272+
return a.age - b.age;
273+
}
274+
return 0;
275+
};
276+
237277
for (const [type, rawTypeStreams] of streamsByType.entries()) {
238278
if (type.startsWith('passthrough-')) {
239279
rawTypeStreams.forEach((stream) => processedStreams.add(stream));
@@ -264,8 +304,7 @@ class StreamDeduplicator {
264304
let selectedStream = typeStreams.sort((a, b) => {
265305
const lc = libraryCmp(a, b);
266306
if (lc !== 0) return lc;
267-
// so a specific type may either have both streams not have a service, or both streams have a service
268-
// if both streams have a service, then we can simpl
307+
269308
let aProviderIndex =
270309
this.userData.services
271310
?.filter((service) => service.enabled)
@@ -282,30 +321,23 @@ class StreamDeduplicator {
282321
return aProviderIndex - bProviderIndex;
283322
}
284323

285-
// look at seeders for p2p and uncached streams
286-
if (
287-
(type === 'p2p' || type === 'uncached') &&
288-
a.torrent?.seeders &&
289-
b.torrent?.seeders
290-
) {
291-
return (b.torrent.seeders || 0) - (a.torrent.seeders || 0);
292-
}
293-
294-
// now look at the addon index
324+
const tb = tiebreakerCmp(a, b, type, 'before_addon');
325+
if (tb !== 0) return tb;
295326

327+
// the addon index MUST exist, its not possible for it to not exist
296328
const aAddonIndex = this.userData.presets.findIndex(
297329
(preset) => preset.instanceId === a.addon.preset.id
298330
);
299331
const bAddonIndex = this.userData.presets.findIndex(
300332
(preset) => preset.instanceId === b.addon.preset.id
301333
);
302-
303-
// the addon index MUST exist, its not possible for it to not exist
304334
if (aAddonIndex !== bAddonIndex) {
305335
return aAddonIndex - bAddonIndex;
306336
}
307337

308-
// now look at stream type
338+
const tb2 = tiebreakerCmp(a, b, type, 'after_addon');
339+
if (tb2 !== 0) return tb2;
340+
309341
let aTypeIndex =
310342
this.userData.preferredStreamTypes?.findIndex(
311343
(type) => type === a.type
@@ -314,10 +346,8 @@ class StreamDeduplicator {
314346
this.userData.preferredStreamTypes?.findIndex(
315347
(type) => type === b.type
316348
) ?? 0;
317-
318349
aTypeIndex = aTypeIndex === -1 ? Infinity : aTypeIndex;
319350
bTypeIndex = bTypeIndex === -1 ? Infinity : bTypeIndex;
320-
321351
if (aTypeIndex !== bTypeIndex) {
322352
return aTypeIndex - bTypeIndex;
323353
}
@@ -348,6 +378,10 @@ class StreamDeduplicator {
348378
return serviceStreams.sort((a, b) => {
349379
const lc = libraryCmp(a, b);
350380
if (lc !== 0) return lc;
381+
382+
const tb = tiebreakerCmp(a, b, type, 'before_addon');
383+
if (tb !== 0) return tb;
384+
351385
let aAddonIndex = this.userData.presets.findIndex(
352386
(preset) => preset.instanceId === a.addon.preset.id
353387
);
@@ -360,7 +394,9 @@ class StreamDeduplicator {
360394
return aAddonIndex - bAddonIndex;
361395
}
362396

363-
// now look at stream type
397+
const tb2 = tiebreakerCmp(a, b, type, 'after_addon');
398+
if (tb2 !== 0) return tb2;
399+
364400
let aTypeIndex =
365401
this.userData.preferredStreamTypes?.findIndex(
366402
(type) => type === a.type
@@ -375,10 +411,6 @@ class StreamDeduplicator {
375411
return aTypeIndex - bTypeIndex;
376412
}
377413

378-
// look at seeders for p2p and uncached streams
379-
if (type === 'p2p' || type === 'uncached') {
380-
return (b.torrent?.seeders || 0) - (a.torrent?.seeders || 0);
381-
}
382414
return 0;
383415
})[0];
384416
});
@@ -420,10 +452,7 @@ class StreamDeduplicator {
420452
if (aServiceIndex !== bServiceIndex) {
421453
return aServiceIndex - bServiceIndex;
422454
}
423-
if (type === 'p2p' || type === 'uncached') {
424-
return (b.torrent?.seeders || 0) - (a.torrent?.seeders || 0);
425-
}
426-
return 0;
455+
return tiebreakerCmp(a, b, type, 'any');
427456
})[0];
428457
});
429458
for (const stream of perAddonStreams) {

packages/core/src/utils/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,11 @@ export const DEDUPLICATOR_LIBRARY_BEHAVIOURS = [
840840
'exclusive',
841841
] as const;
842842

843+
export const DEDUPLICATOR_TIEBREAKERS = [
844+
'torrent_seeders',
845+
'usenet_age',
846+
] as const;
847+
843848
export const SMART_DETECT_ATTRIBUTES = [
844849
'size',
845850
'bitrate',

packages/frontend/src/components/menu/filters/index.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
LANGUAGES,
4242
TYPES,
4343
DEDUPLICATOR_KEYS,
44+
DEDUPLICATOR_TIEBREAKERS,
4445
SMART_DETECT_ATTRIBUTES,
4546
DEFAULT_SMART_DETECT_ATTRIBUTES,
4647
AUDIO_CHANNELS,
@@ -3386,9 +3387,10 @@ function Content() {
33863387
<span className="font-medium">Single Result</span>
33873388
<p className="text-sm text-[--muted] mt-1">
33883389
Keeps only one result from your highest priority
3389-
service and highest priority addon. If it is a P2P
3390-
or uncached result, it prioritises the number of
3391-
seeders over addon priority.
3390+
service and highest priority addon. Enabled
3391+
tiebreakers (torrent seeders, usenet age) are
3392+
applied at the position configured below - either
3393+
before or after addon order is considered.
33923394
</p>
33933395
</div>
33943396
<div>
@@ -3644,6 +3646,86 @@ function Content() {
36443646
{ label: 'Exclusive', value: 'exclusive' },
36453647
]}
36463648
/>
3649+
{DEDUPLICATOR_TIEBREAKERS.map((tiebreakerType) => {
3650+
const label =
3651+
tiebreakerType === 'torrent_seeders'
3652+
? 'Torrent Seeders Tiebreaker'
3653+
: 'Usenet Age Tiebreaker';
3654+
const help =
3655+
tiebreakerType === 'torrent_seeders'
3656+
? 'When choosing between duplicate P2P or uncached streams, prefer the one with more seeders. Controls where in the priority order this check runs relative to addon order.'
3657+
: 'When choosing between duplicate Usenet streams, prefer the newer post (posts released within the last 24 hours are considered equal). Controls where in the priority order this check runs relative to addon order.';
3658+
const defaultPosition = 'before_addon';
3659+
const currentTiebreakers = userData.deduplicator
3660+
?.tiebreakers ?? [
3661+
{
3662+
type: 'torrent_seeders',
3663+
position: 'before_addon',
3664+
},
3665+
{ type: 'usenet_age', position: 'before_addon' },
3666+
];
3667+
const entry = currentTiebreakers.find(
3668+
(t) => t.type === tiebreakerType
3669+
);
3670+
const value = entry?.position ?? 'disabled';
3671+
return (
3672+
<Select
3673+
key={tiebreakerType}
3674+
disabled={!userData.deduplicator?.enabled}
3675+
label={label}
3676+
help={help}
3677+
value={value ?? defaultPosition}
3678+
onValueChange={(newValue) => {
3679+
setUserData((prev) => {
3680+
const existing = prev.deduplicator
3681+
?.tiebreakers ?? [
3682+
{
3683+
type: 'torrent_seeders' as const,
3684+
position: 'before_addon' as const,
3685+
},
3686+
{
3687+
type: 'usenet_age' as const,
3688+
position: 'after_addon' as const,
3689+
},
3690+
];
3691+
const filtered = existing.filter(
3692+
(t) => t.type !== tiebreakerType
3693+
);
3694+
const updated =
3695+
newValue === 'disabled'
3696+
? filtered
3697+
: [
3698+
...filtered,
3699+
{
3700+
type: tiebreakerType,
3701+
position: newValue as
3702+
| 'before_addon'
3703+
| 'after_addon',
3704+
},
3705+
];
3706+
return {
3707+
...prev,
3708+
deduplicator: {
3709+
...prev.deduplicator,
3710+
tiebreakers: updated,
3711+
},
3712+
};
3713+
});
3714+
}}
3715+
options={[
3716+
{ label: 'Disabled', value: 'disabled' },
3717+
{
3718+
label: 'Before Addon Order',
3719+
value: 'before_addon',
3720+
},
3721+
{
3722+
label: 'After Addon Order',
3723+
value: 'after_addon',
3724+
},
3725+
]}
3726+
/>
3727+
);
3728+
})}
36473729
</SettingsCard>
36483730
</>
36493731
)}

0 commit comments

Comments
 (0)