Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/core/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export type Resource = z.infer<typeof ResourceSchema>;

const ResourceList = z.array(ResourceSchema);

const ForceToTopSchema = z.union([
z.boolean(),
z.enum(['streams', 'catalogs', 'both', 'none']),
]);

export type ForceToTopSetting = z.infer<typeof ForceToTopSchema>;

const AddonSchema = z.object({
instanceId: z.string().min(1).optional(), // uniquely identifies the addon in a given list of addons
preset: z.object({
Expand All @@ -137,7 +144,7 @@ const AddonSchema = z.object({
library: z.boolean().optional(),
formatPassthrough: z.boolean().optional(),
resultPassthrough: z.boolean().optional(),
forceToTop: z.boolean().optional(),
forceToTop: ForceToTopSchema.optional(),
headers: z.record(z.string().min(1), z.string().min(1)).optional(),
ip: z.union([z.ipv4(), z.ipv6()]).optional(),
});
Expand Down Expand Up @@ -323,6 +330,7 @@ const MergedCatalog = z.object({
'releaseDateDesc', // sort by release date (newest first)
])
.optional(), // defaults to 'sequential' if not specified
forceToTop: z.boolean().optional(), // push this merged catalog above others when true
});

export type MergedCatalog = z.infer<typeof MergedCatalog>;
Expand Down
92 changes: 80 additions & 12 deletions packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export class AIOStreams {
private finalResources: StrictManifestResource[] = [];
private finalCatalogs: Manifest['catalogs'] = [];
private finalAddonCatalogs: Manifest['addonCatalogs'] = [];
private catalogsForcedToTop: Set<string> = new Set();
private isInitialised: boolean = false;
private addons: Addon[] = [];
private proxifier: Proxifier;
Expand Down Expand Up @@ -1408,6 +1409,7 @@ export class AIOStreams {
}

private async fetchResources() {
this.catalogsForcedToTop.clear();
for (const [instanceId, manifest] of Object.entries(this.manifests)) {
if (!manifest) continue;

Expand Down Expand Up @@ -1458,6 +1460,10 @@ export class AIOStreams {
continue;
}

const shouldForceCatalogs = this.shouldForceCatalogsToTop(
addon.forceToTop
);

// Filter and merge resources
for (const resource of addonResources) {
if (
Expand Down Expand Up @@ -1536,22 +1542,40 @@ export class AIOStreams {
!addon.resources?.length ||
(addon.resources && addon.resources.includes('catalog'))
) {
this.finalCatalogs.push(
...manifest.catalogs.map((catalog) => ({
const catalogsToAdd = manifest.catalogs.map((catalog) => {
const catalogWithId = {
...catalog,
id: `${addon.instanceId}.${catalog.id}`,
}))
);
};
if (shouldForceCatalogs) {
this.catalogsForcedToTop.add(
this.getCatalogForceKey(catalogWithId)
);
}
return catalogWithId;
});

this.finalCatalogs.push(...catalogsToAdd);
}

// add all addon catalogs, prefixing id with index
if (manifest.addonCatalogs) {
this.finalAddonCatalogs!.push(
...(manifest.addonCatalogs || []).map((catalog) => ({
...catalog,
id: `${addon.instanceId}.${catalog.id}`,
}))
const addonCatalogsToAdd = (manifest.addonCatalogs || []).map(
(catalog) => {
const catalogWithId = {
...catalog,
id: `${addon.instanceId}.${catalog.id}`,
};
if (shouldForceCatalogs) {
this.catalogsForcedToTop.add(
this.getCatalogForceKey(catalogWithId)
);
}
return catalogWithId;
}
);

this.finalAddonCatalogs!.push(...addonCatalogsToAdd);
}

this.supportedResources[instanceId] = addonResources;
Expand Down Expand Up @@ -1625,12 +1649,20 @@ export class AIOStreams {
);
for (const mc of enabledMergedCatalogs) {
const mergedExtras = this.buildMergedCatalogExtras(mc.catalogIds);
this.finalCatalogs.push({
const mergedCatalog = {
id: mc.id,
name: mc.name,
type: mc.type,
extra: mergedExtras.length > 0 ? mergedExtras : undefined,
});
};

if (mc.forceToTop) {
this.catalogsForcedToTop.add(
this.getCatalogForceKey({ id: mc.id, type: mc.type })
);
}

this.finalCatalogs.push(mergedCatalog);
}
}

Expand Down Expand Up @@ -1691,7 +1723,6 @@ export class AIOStreams {
if (modification?.name) {
catalog.name = modification.name;
}

// checking that no extras are required already
// if its a non genre extra, then its just not possible as it would lead to having 2 required extras.
// if it is the genre extra that is required, then there isnt a need to apply the modification as its already only on discover
Expand Down Expand Up @@ -1750,6 +1781,31 @@ export class AIOStreams {
return catalog;
});
}

// Move forced-to-top catalogs (including merged) ahead while keeping the
// previously determined relative order.
if (this.catalogsForcedToTop.size > 0) {
const forcedCatalogs = this.finalCatalogs.filter((catalog) =>
this.isCatalogForcedToTop(catalog)
);
const remainingCatalogs = this.finalCatalogs.filter(
(catalog) => !this.isCatalogForcedToTop(catalog)
);
this.finalCatalogs = [...forcedCatalogs, ...remainingCatalogs];
}

if (this.catalogsForcedToTop.size > 0 && this.finalAddonCatalogs?.length) {
const forcedAddonCatalogs = this.finalAddonCatalogs.filter((catalog) =>
this.isCatalogForcedToTop(catalog)
);
const remainingAddonCatalogs = this.finalAddonCatalogs.filter(
(catalog) => !this.isCatalogForcedToTop(catalog)
);
this.finalAddonCatalogs = [
...forcedAddonCatalogs,
...remainingAddonCatalogs,
];
}
}

/**
Expand Down Expand Up @@ -1871,6 +1927,18 @@ export class AIOStreams {
return mergedExtras;
}

private getCatalogForceKey(catalog: { id: string; type: string }): string {
return `${catalog.id}::${catalog.type}`;
}

private isCatalogForcedToTop(catalog: { id: string; type: string }): boolean {
return this.catalogsForcedToTop.has(this.getCatalogForceKey(catalog));
}

private shouldForceCatalogsToTop(forceToTop: Addon['forceToTop']): boolean {
return forceToTop === 'catalogs' || forceToTop === 'both';
}

public getResources(): StrictManifestResource[] {
this.checkInitialised();
return this.finalResources;
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/presets/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Preset, baseOptions } from './preset.js';
import { Env, RESOURCES } from '../utils/index.js';
import { constants } from '../utils/index.js';

type ForceToTopOption = 'streams' | 'catalogs' | 'both' | 'none';

export class CustomPreset extends Preset {
static override get METADATA() {
const options: Option[] = [
Expand Down Expand Up @@ -100,10 +102,16 @@ export class CustomPreset extends Preset {
id: 'forceToTop',
name: 'Force to Top',
description:
'Whether to force results from this addon to be pushed to the top of the stream list.',
type: 'boolean',
'Choose whether streams and/or catalogs from this addon should be pinned to the top.',
type: 'select',
required: false,
default: false,
default: 'none',
options: [
{ label: 'Neither', value: 'none' },
{ label: 'Streams only', value: 'streams' },
{ label: 'Catalogs only', value: 'catalogs' },
{ label: 'Streams and catalogs', value: 'both' },
],
},
];

Expand Down Expand Up @@ -148,6 +156,8 @@ export class CustomPreset extends Preset {
userData: UserData,
options: Record<string, any>
): Addon {
const forceToTop = this.normalizeForceToTop(options.forceToTop);

return {
name: options.name || this.METADATA.NAME,
manifestUrl: options.manifestUrl,
Expand All @@ -164,10 +174,28 @@ export class CustomPreset extends Preset {
formatPassthrough:
options.formatPassthrough ?? options.streamPassthrough ?? false,
resultPassthrough: options.resultPassthrough ?? false,
forceToTop: options.forceToTop ?? false,
forceToTop,
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
};
}

private static normalizeForceToTop(
forceToTop: any
): ForceToTopOption {
if (forceToTop === 'streams' || forceToTop === 'catalogs') {
return forceToTop;
}
if (forceToTop === 'both') {
return 'both';
}
if (forceToTop === 'none' || forceToTop === false || forceToTop === undefined) {
return 'none';
}
if (forceToTop === true) {
return 'streams';
}
return 'none';
}
}
16 changes: 14 additions & 2 deletions packages/core/src/streams/sorter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ class StreamSorter {
type: string
): Promise<ParsedStream[]> {
const forcedToTopStreams = allStreams.filter(
(stream) => stream.addon.forceToTop
(stream) => StreamSorter.shouldForceStreamToTop(stream.addon.forceToTop)
);
const streams = allStreams.filter(
(stream) => !StreamSorter.shouldForceStreamToTop(stream.addon.forceToTop)
);
const streams = allStreams.filter((stream) => !stream.addon.forceToTop);

let primarySortCriteria = this.userData.sortCriteria.global;
let cachedSortCriteria = this.userData.sortCriteria.cached;
Expand Down Expand Up @@ -135,6 +137,16 @@ class StreamSorter {
return [...forcedToTopStreams, ...sortedStreams];
}

private static shouldForceStreamToTop(
forceToTop: ParsedStream['addon']['forceToTop']
): boolean {
return (
forceToTop === true ||
forceToTop === 'streams' ||
forceToTop === 'both'
);
}

private dynamicSortKey(
stream: ParsedStream,
sortCriteria: SortCriterion[],
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,14 @@ function validateOption(
}
return value;
}
if (
option.id === 'forceToTop' &&
option.type === 'select' &&
typeof value === 'boolean'
) {
// Legacy boolean configs: true means streams-only, false means none
value = value ? 'streams' : 'none';
}
if (option.type === 'multi-select') {
if (!Array.isArray(value)) {
throw new Error(
Expand Down
15 changes: 15 additions & 0 deletions packages/frontend/src/components/menu/addons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,7 @@ function MergedCatalogsCard() {
]);
const [mergeMethod, setMergeMethod] =
useState<MergedCatalog['mergeMethod']>('sequential');
const [forceToTop, setForceToTop] = useState(false);
const [catalogSearch, setCatalogSearch] = useState('');
const [expandedAddons, setExpandedAddons] = useState<Set<string>>(new Set());
const [pendingDeleteMergedCatalogId, setPendingDeleteMergedCatalogId] =
Expand Down Expand Up @@ -1922,6 +1923,7 @@ function MergedCatalogsCard() {
setSelectedCatalogs([]);
setDedupeMethods(['id']);
setMergeMethod('sequential');
setForceToTop(false);
setCatalogSearch('');
setExpandedAddons(new Set());
setModalOpen(true);
Expand All @@ -1934,6 +1936,7 @@ function MergedCatalogsCard() {
setSelectedCatalogs(mergedCatalog.catalogIds);
setDedupeMethods(mergedCatalog.deduplicationMethods ?? ['id']);
setMergeMethod(mergedCatalog.mergeMethod ?? 'sequential');
setForceToTop(mergedCatalog.forceToTop ?? false);
setCatalogSearch('');
setExpandedAddons(new Set());
setModalOpen(true);
Expand Down Expand Up @@ -1982,6 +1985,7 @@ function MergedCatalogsCard() {
deduplicationMethods:
dedupeMethods.length > 0 ? dedupeMethods : undefined,
mergeMethod: mergeMethod ?? 'sequential',
forceToTop,
}
: mc
),
Expand All @@ -2002,6 +2006,7 @@ function MergedCatalogsCard() {
deduplicationMethods:
dedupeMethods.length > 0 ? dedupeMethods : undefined,
mergeMethod: mergeMethod ?? 'sequential',
forceToTop,
},
],
}));
Expand Down Expand Up @@ -2440,6 +2445,16 @@ function MergedCatalogsCard() {
}
/>

<div className="flex items-center justify-between rounded-[--radius-md] border px-3 py-2">
<div>
<p className="text-sm font-medium">Force to Top</p>
<p className="text-xs text-[--muted]">
Pin this merged catalog above others in the catalog list.
</p>
</div>
<Switch value={forceToTop} onValueChange={setForceToTop} />
</div>

{(mergeMethod === 'imdbRating' ||
mergeMethod === 'releaseDateDesc' ||
mergeMethod === 'releaseDateAsc') && (
Expand Down