feat: add ability to customise group behaviour

This commit is contained in:
Viren070
2025-09-13 18:39:26 +01:00
parent bc06b1a9d4
commit 8dc85ba594
6 changed files with 169 additions and 92 deletions
+21 -7
View File
@@ -360,14 +360,28 @@ export const UserDataSchema = z.object({
requiredStreamExpressions: z.array(z.string().min(1).max(3000)).optional(),
preferredStreamExpressions: z.array(z.string().min(1).max(3000)).optional(),
includedStreamExpressions: z.array(z.string().min(1).max(3000)).optional(),
disableGroups: z.boolean().optional(),
// disableGroups: z.boolean().optional(),
// groups: z
// .array(
// z.object({
// addons: z.array(z.string().min(1)),
// condition: z.string().min(1).max(200),
// })
// )
// .optional(),
groups: z
.array(
z.object({
addons: z.array(z.string().min(1)),
condition: z.string().min(1).max(200),
})
)
.object({
enabled: z.boolean().optional(),
groupings: z
.array(
z.object({
addons: z.array(z.string().min(1)),
condition: z.string().min(1).max(200),
})
)
.optional(),
behaviour: z.enum(['sequential', 'parallel']).optional(),
})
.optional(),
sortCriteria: z.object({
// global must be defined.
+2 -1
View File
@@ -15,6 +15,7 @@ import {
verifyHash,
validateConfig,
formatZodError,
applyMigrations,
} from '../utils';
const APIError = constants.APIError;
@@ -193,7 +194,7 @@ export class UserRepository {
Env.TRUSTED_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ??
false;
logger.info(`Retrieved configuration for user ${uuid}`);
return decryptedConfig;
return applyMigrations(decryptedConfig);
} catch (error) {
logger.error(
`Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}`
+81 -61
View File
@@ -168,52 +168,54 @@ class StreamFetcher {
// If groups are configured, handle group-based fetching
if (
this.userData.groups &&
this.userData.groups.length > 0 &&
this.userData.disableGroups !== true
this.userData.groups?.groupings &&
this.userData.groups.groupings.length > 0 &&
this.userData.groups.enabled !== false
) {
// add addons that are not assigned to any group to the first group
const unassignedAddons = addons.filter(
(addon) =>
!this.userData.groups!.some((group) =>
!this.userData.groups?.groupings?.some((group) =>
group.addons.includes(addon.preset.id)
)
);
if (unassignedAddons.length > 0) {
this.userData.groups[0].addons.push(
if (unassignedAddons.length > 0 && this.userData.groups.groupings[0]) {
this.userData.groups.groupings[0].addons.push(
...unassignedAddons.map((addon) => addon.preset.id)
);
}
const groupPromises = this.userData.groups.map((group) => {
const groupAddons = addons.filter(
(addon) => addon.preset.id && group.addons.includes(addon.preset.id)
);
logger.info(
`Queueing fetch for group with ${groupAddons.length} addons.`
);
return fetchFromGroup(groupAddons);
});
const behaviour = this.userData.groups.behaviour || 'parallel';
let totalTimeTaken = 0;
let previousGroupStreams: ParsedStream[] = [];
let previousGroupTimeTaken = 0;
for (let i = 0; i < groupPromises.length; i++) {
const groupResult = await groupPromises[i];
const group = this.userData.groups[i];
if (behaviour === 'parallel') {
// Fetch all groups in parallel but still evaluate conditions
const groupPromises = this.userData.groups.groupings.map((group) => {
const groupAddons = addons.filter(
(addon) => addon.preset.id && group.addons.includes(addon.preset.id)
);
logger.info(
`Queueing parallel fetch for group with ${groupAddons.length} addons.`
);
return fetchFromGroup(groupAddons);
});
if (i === 0) {
allStreams.push(...groupResult.streams);
allErrors.push(...groupResult.errors);
allStatisticStreams.push(...groupResult.statistics);
totalTimeTaken = groupResult.totalTime;
previousGroupStreams = groupResult.streams;
previousGroupTimeTaken = groupResult.totalTime;
for (let i = 0; i < this.userData.groups.groupings.length; i++) {
const groupResult = await groupPromises[i];
const group = this.userData.groups.groupings[i];
// After the first group, check the condition for the second group
if (groupPromises.length > 1) {
const nextGroup = this.userData.groups[1];
if (!nextGroup.condition || !nextGroup.addons.length) continue;
if (i === 0) {
allStreams.push(...groupResult.streams);
allErrors.push(...groupResult.errors);
allStatisticStreams.push(...groupResult.statistics);
totalTimeTaken = groupResult.totalTime;
previousGroupStreams = groupResult.streams;
previousGroupTimeTaken = groupResult.totalTime;
} else {
// For groups other than the first, check their condition
if (!group.condition || !group.addons.length) continue;
const evaluator = new GroupConditionEvaluator(
previousGroupStreams,
@@ -222,46 +224,64 @@ class StreamFetcher {
totalTimeTaken,
queryType
);
const shouldFetchNext = await evaluator.evaluate(
nextGroup.condition
);
const shouldInclude = await evaluator.evaluate(group.condition);
if (!shouldFetchNext) {
if (shouldInclude) {
logger.info(
`Condition not met for group 2 based on group 1 results. Halting further processing.`
`Condition met for parallel group ${i + 1}, including streams.`
);
allStreams.push(...groupResult.streams);
allErrors.push(...groupResult.errors);
allStatisticStreams.push(...groupResult.statistics);
totalTimeTaken = Math.max(totalTimeTaken, groupResult.totalTime);
previousGroupStreams = groupResult.streams;
previousGroupTimeTaken = groupResult.totalTime;
} else {
logger.info(
`Condition not met for parallel group ${i + 1}, skipping streams.`
);
break; // Exit the loop, returning only group 1 streams
}
}
} else {
// For groups other than the first, check their condition before processing
if (!group.condition || !group.addons.length) continue;
}
} else {
// Sequential behavior - fetch and evaluate one group at a time
for (let i = 0; i < this.userData.groups.groupings.length; i++) {
const group = this.userData.groups.groupings[i];
const evaluator = new GroupConditionEvaluator(
previousGroupStreams,
allStreams,
previousGroupTimeTaken,
totalTimeTaken,
queryType
);
const shouldFetch = await evaluator.evaluate(group.condition);
// For groups after the first, check condition before fetching
if (i > 0 && group.condition) {
const evaluator = new GroupConditionEvaluator(
previousGroupStreams,
allStreams,
previousGroupTimeTaken,
totalTimeTaken,
queryType
);
const shouldFetch = await evaluator.evaluate(group.condition);
if (shouldFetch) {
logger.info(
`Condition met for group ${i + 1}, processing streams.`
);
allStreams.push(...groupResult.streams);
allErrors.push(...groupResult.errors);
allStatisticStreams.push(...groupResult.statistics);
totalTimeTaken += groupResult.totalTime;
previousGroupStreams = groupResult.streams;
previousGroupTimeTaken = groupResult.totalTime;
} else {
logger.info(
`Condition not met for group ${i + 1}, skipping remaining groups.`
);
break; // Stop processing any more groups
if (!shouldFetch) {
logger.info(
`Condition not met for sequential group ${i + 1}, stopping.`
);
break;
}
}
const groupAddons = addons.filter(
(addon) => addon.preset.id && group.addons.includes(addon.preset.id)
);
logger.info(
`Fetching from sequential group ${i + 1} with ${groupAddons.length} addons.`
);
const groupResult = await fetchFromGroup(groupAddons);
allStreams.push(...groupResult.streams);
allErrors.push(...groupResult.errors);
allStatisticStreams.push(...groupResult.statistics);
totalTimeTaken += groupResult.totalTime;
previousGroupStreams = groupResult.streams;
previousGroupTimeTaken = groupResult.totalTime;
}
}
} else {
+13 -5
View File
@@ -352,8 +352,8 @@ export async function validateConfig(
}
}
if (config.groups) {
for (const group of config.groups) {
if (config.groups?.groupings) {
for (const group of config.groups.groupings) {
await validateGroup(group);
}
}
@@ -456,8 +456,8 @@ function removeInvalidPresetReferences(config: UserData) {
existingPresetIds?.includes(addon)
);
}
if (config.groups) {
config.groups = config.groups.map((group) => ({
if (config.groups?.groupings) {
config.groups.groupings = config.groups.groupings.map((group) => ({
...group,
addons: group.addons?.filter((addon) =>
existingPresetIds?.includes(addon)
@@ -467,7 +467,7 @@ function removeInvalidPresetReferences(config: UserData) {
return config;
}
export function applyMigrations(config: UserData): UserData {
export function applyMigrations(config: any): UserData {
if (
config.deduplicator &&
typeof config.deduplicator.multiGroupBehaviour === 'string'
@@ -495,6 +495,14 @@ export function applyMigrations(config: UserData): UserData {
};
delete config.titleMatching.matchYear;
}
if (Array.isArray(config.groups)) {
config.groups = {
enabled: config.disableGroups ? false : true,
groupings: config.groups,
behaviour: 'parallel',
};
}
return config;
}
@@ -74,6 +74,7 @@ import { IoExtensionPuzzle } from 'react-icons/io5';
import { NumberInput } from '../ui/number-input';
import { useDisclosure } from '@/hooks/disclosure';
import { useMode } from '@/context/mode';
import { Select } from '../ui/select';
interface CatalogModification {
id: string;
@@ -1191,7 +1192,7 @@ function AddonGroupCard() {
// Helper function to get presets that are not in any group except the current one
const getAvailablePresets = (currentGroupIndex: number) => {
const presetsInOtherGroups = new Set(
userData.groups?.flatMap((group, idx) =>
userData.groups?.groupings?.flatMap((group, idx) =>
idx !== currentGroupIndex ? group.addons : []
) || []
);
@@ -1213,7 +1214,7 @@ function AddonGroupCard() {
) => {
setUserData((prev) => {
// Initialize groups array if it doesn't exist
const currentGroups = prev.groups || [];
const currentGroups = prev.groups?.groupings || [];
// Create a new array with all existing groups
const newGroups = [...currentGroups];
@@ -1231,7 +1232,10 @@ function AddonGroupCard() {
return {
...prev,
groups: newGroups,
groups: {
...prev.groups,
groupings: newGroups,
},
};
});
};
@@ -1259,21 +1263,46 @@ function AddonGroupCard() {
for a detailed guide to using groups.
</div>
<Switch
label="Disable Groups"
value={userData.disableGroups ?? false}
label="Enable"
value={userData.groups?.enabled ?? false}
onValueChange={(value) => {
setUserData((prev) => ({ ...prev, disableGroups: value }));
setUserData((prev) => ({
...prev,
groups: { ...prev.groups, enabled: value },
}));
}}
side="right"
help="If enabled, groups will be ignored and all addons will be used."
/>
{(userData.groups || []).map((group, index) => (
<Select
label="Behaviour"
value={userData.groups?.behaviour ?? 'parallel'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
groups: {
...prev.groups,
behaviour: value as 'sequential' | 'parallel',
},
}));
}}
options={[
{ label: 'Parallel', value: 'parallel' },
{ label: 'Sequential', value: 'sequential' },
]}
disabled={userData.groups?.enabled === false}
help={
userData.groups?.behaviour === 'sequential'
? 'Streams are fetched from the first group only to begin with. If the condition for the next group is met, streams are fetched from the next group, and so on.'
: 'Streams are fetched from all groups at the same time. When a condition is not met, results from its group onwards are simply not shown.'
}
/>
{(userData.groups?.groupings || []).map((group, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 flex gap-2">
<div className="flex-1">
<Combobox
multiple
disabled={userData.disableGroups}
disabled={userData.groups?.enabled === false}
value={group.addons}
options={getAvailablePresets(index)}
emptyMessage="You haven't installed any addons yet or they are already in a group"
@@ -1287,7 +1316,7 @@ function AddonGroupCard() {
<div className="flex-1">
<TextInput
value={index === 0 ? 'true' : group.condition}
disabled={index === 0 || userData.disableGroups}
disabled={index === 0 || userData.groups?.enabled === false}
label="Condition"
placeholder="Enter condition"
onValueChange={(value) => {
@@ -1299,16 +1328,16 @@ function AddonGroupCard() {
<IconButton
size="sm"
rounded
disabled={userData.disableGroups}
disabled={userData.groups?.enabled === false}
icon={<FaRegTrashAlt />}
intent="alert-subtle"
onClick={() => {
setUserData((prev) => {
const newGroups = [...(prev.groups || [])];
const newGroups = [...(prev.groups?.groupings || [])];
newGroups.splice(index, 1);
return {
...prev,
groups: newGroups,
groups: { ...prev.groups, groupings: newGroups },
};
});
}}
@@ -1321,13 +1350,16 @@ function AddonGroupCard() {
size="sm"
intent="primary-subtle"
icon={<FaPlus />}
disabled={userData.disableGroups}
disabled={userData.groups?.enabled === false}
onClick={() => {
setUserData((prev) => {
const currentGroups = prev.groups || [];
const currentGroups = prev.groups?.groupings || [];
return {
...prev,
groups: [...currentGroups, { addons: [], condition: '' }],
groups: {
...prev.groups,
groupings: [...currentGroups, { addons: [], condition: '' }],
},
};
});
}}
@@ -27,7 +27,7 @@ import {
ConfirmationDialog,
useConfirmationDialog,
} from '../shared/confirmation-dialog';
import { UserData } from '@aiostreams/core';
import { applyMigrations, UserData } from '@aiostreams/core';
export function SaveInstallMenu() {
return (
@@ -164,7 +164,9 @@ function Content() {
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
const json = applyMigrations(
JSON.parse(event.target?.result as string)
);
// const validate = UserDataSchema.safeParse(json);
// if (!validate.success) {
// toast.error('Failed to import configuration: Invalid JSON file');