diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 5a6a4275d..b8fb4f060 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -119,6 +119,13 @@ export type Resource = z.infer; const ResourceList = z.array(ResourceSchema); +const ForceToTopSchema = z.union([ + z.boolean(), + z.enum(['streams', 'catalogs', 'both', 'none']), +]); + +export type ForceToTopSetting = z.infer; + const AddonSchema = z.object({ instanceId: z.string().min(1).optional(), // uniquely identifies the addon in a given list of addons preset: z.object({ @@ -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(), }); @@ -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; diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 2452c05c6..8f6720528 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -91,6 +91,7 @@ export class AIOStreams { private finalResources: StrictManifestResource[] = []; private finalCatalogs: Manifest['catalogs'] = []; private finalAddonCatalogs: Manifest['addonCatalogs'] = []; + private catalogsForcedToTop: Set = new Set(); private isInitialised: boolean = false; private addons: Addon[] = []; private proxifier: Proxifier; @@ -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; @@ -1458,6 +1460,10 @@ export class AIOStreams { continue; } + const shouldForceCatalogs = this.shouldForceCatalogsToTop( + addon.forceToTop + ); + // Filter and merge resources for (const resource of addonResources) { if ( @@ -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; @@ -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); } } @@ -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 @@ -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, + ]; + } } /** @@ -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; diff --git a/packages/core/src/presets/custom.ts b/packages/core/src/presets/custom.ts index 72fb549d2..8fa2c05d6 100644 --- a/packages/core/src/presets/custom.ts +++ b/packages/core/src/presets/custom.ts @@ -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[] = [ @@ -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' }, + ], }, ]; @@ -148,6 +156,8 @@ export class CustomPreset extends Preset { userData: UserData, options: Record ): Addon { + const forceToTop = this.normalizeForceToTop(options.forceToTop); + return { name: options.name || this.METADATA.NAME, manifestUrl: options.manifestUrl, @@ -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'; + } } diff --git a/packages/core/src/streams/sorter.ts b/packages/core/src/streams/sorter.ts index f03784ae5..d9fce0a7b 100644 --- a/packages/core/src/streams/sorter.ts +++ b/packages/core/src/streams/sorter.ts @@ -19,9 +19,11 @@ class StreamSorter { type: string ): Promise { 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; @@ -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[], diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index 0742e1414..4aec0ae4c 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -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( diff --git a/packages/frontend/src/components/menu/addons.tsx b/packages/frontend/src/components/menu/addons.tsx index 4720efb61..8b5af745d 100644 --- a/packages/frontend/src/components/menu/addons.tsx +++ b/packages/frontend/src/components/menu/addons.tsx @@ -1809,6 +1809,7 @@ function MergedCatalogsCard() { ]); const [mergeMethod, setMergeMethod] = useState('sequential'); + const [forceToTop, setForceToTop] = useState(false); const [catalogSearch, setCatalogSearch] = useState(''); const [expandedAddons, setExpandedAddons] = useState>(new Set()); const [pendingDeleteMergedCatalogId, setPendingDeleteMergedCatalogId] = @@ -1922,6 +1923,7 @@ function MergedCatalogsCard() { setSelectedCatalogs([]); setDedupeMethods(['id']); setMergeMethod('sequential'); + setForceToTop(false); setCatalogSearch(''); setExpandedAddons(new Set()); setModalOpen(true); @@ -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); @@ -1982,6 +1985,7 @@ function MergedCatalogsCard() { deduplicationMethods: dedupeMethods.length > 0 ? dedupeMethods : undefined, mergeMethod: mergeMethod ?? 'sequential', + forceToTop, } : mc ), @@ -2002,6 +2006,7 @@ function MergedCatalogsCard() { deduplicationMethods: dedupeMethods.length > 0 ? dedupeMethods : undefined, mergeMethod: mergeMethod ?? 'sequential', + forceToTop, }, ], })); @@ -2440,6 +2445,16 @@ function MergedCatalogsCard() { } /> +
+
+

Force to Top

+

+ Pin this merged catalog above others in the catalog list. +

+
+ +
+ {(mergeMethod === 'imdbRating' || mergeMethod === 'releaseDateDesc' || mergeMethod === 'releaseDateAsc') && (