diff --git a/packages/addon/src/addon.ts b/packages/addon/src/addon.ts index 3f31699b..76cd4ad6 100644 --- a/packages/addon/src/addon.ts +++ b/packages/addon/src/addon.ts @@ -121,6 +121,102 @@ export class AIOStreams { return true; }); + + if (this.config.cleanResults) { + const uniqueStreams: ParsedStream[] = []; + + // Group streams by normalized filename + const streamsByHash = filteredResults.reduce((acc, stream) => { + const normalizedFilename = stream.filename + ? stream.filename.replace(/[^\p{L}\p{N}]/gu, '').toLowerCase() + : undefined; + + if (!normalizedFilename) { + uniqueStreams.push(stream); + return acc; + } + + acc[normalizedFilename] = acc[normalizedFilename] || []; + acc[normalizedFilename].push(stream); + return acc; + }, {} as Record); + + Object.values(streamsByHash).forEach((groupedStreams) => { + if (groupedStreams.length === 1) { + uniqueStreams.push(groupedStreams[0]); + return; + } + //console.log(`==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates`); + //console.log(groupedStreams.map(stream => `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}`)); + // Separate streams into categories + const cachedStreams = groupedStreams.filter(stream => stream.provider?.cached); + const uncachedStreams = groupedStreams.filter(stream => stream.provider && !stream.provider.cached); + const noProviderStreams = groupedStreams.filter(stream => !stream.provider); + + // Select uncached streams by addon priority (one per provider) + const selectedUncachedStreams = Object.values(uncachedStreams.reduce((acc, stream) => { + acc[stream.provider!.id] = acc[stream.provider!.id] || []; + acc[stream.provider!.id].push(stream); + return acc; + }, {} as Record)).map(providerGroup => { + return providerGroup.sort((a, b) => { + const aIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + return aIndex - bIndex; + })[0]; + }); + //selectedUncachedStreams.forEach(stream => console.log(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`)); + + // Select cached streams by provider and addon priority + const selectedCachedStream = cachedStreams.sort((a, b) => { + const aProviderIndex = this.config.services.findIndex(service => service.id === a.provider!.id); + const bProviderIndex = this.config.services.findIndex(service => service.id === b.provider!.id); + + if (aProviderIndex !== bProviderIndex) { + return aProviderIndex - bProviderIndex; + } + + const aAddonIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bAddonIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + + return aAddonIndex - bAddonIndex; + })[0]; + // Select one non-provider stream (highest addon priority) + const selectedNoProviderStream = noProviderStreams.sort((a, b) => { + const aIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bIndex = this.config.addons.findIndex( + addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + return aIndex - bIndex; + })[0]; + + + // Combine selected streams for this group + if (selectedNoProviderStream) { + //console.log(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`); + uniqueStreams.push(selectedNoProviderStream); + } + if (selectedCachedStream) { + //console.log(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`); + uniqueStreams.push(selectedCachedStream); + } + uniqueStreams.push(...selectedUncachedStreams); + }); + + filteredResults = uniqueStreams; + } + + // apply config.maxResultsPerResolution if (this.config.maxResultsPerResolution) { const streamsByResolution = filteredResults.reduce((acc, stream) => { acc[stream.resolution] = acc[stream.resolution] || []; diff --git a/packages/frontend/src/app/configure/page.tsx b/packages/frontend/src/app/configure/page.tsx index 0354370e..949cb9eb 100644 --- a/packages/frontend/src/app/configure/page.tsx +++ b/packages/frontend/src/app/configure/page.tsx @@ -155,6 +155,7 @@ export default function Configure() { const [minMovieSize, setMinMovieSize] = useState(null); const [maxEpisodeSize, setMaxEpisodeSize] = useState(null); const [minEpisodeSize, setMinEpisodeSize] = useState(null); + const [cleanResults, setCleanResults] = useState(false); const [maxResultsPerResolution, setMaxResultsPerResolution] = useState(null); const [disableButtons, setDisableButtons] = useState(false); @@ -196,6 +197,7 @@ export default function Configure() { minMovieSize, maxEpisodeSize, minEpisodeSize, + cleanResults, maxResultsPerResolution, formatter: formatter || 'gdrive', addons, @@ -774,6 +776,34 @@ export default function Configure() { + +
+
+
+

Clean Results

+

+ Attempt to remove duplicate results. This can be duplicate files within the same addon, or only keeping the highest priority + service for a particular file. It also considers the addon's priority when removing duplicates. If you had 2 cached results + for the same file from the same service, it would keep the one with the higher addon priority. +

+
+
+ setCleanResults(e.target.checked)} + // move to the right + style={{ + marginLeft: 'auto', + marginRight: '20px', + width: '25px', + height: '25px', + }} + /> +
+
+
+
diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index c8f2c3dd..4c1e3cea 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -140,6 +140,7 @@ export interface Config { minMovieSize: number | null; maxEpisodeSize: number | null; minEpisodeSize: number | null; + cleanResults: boolean; maxResultsPerResolution: number | null; addons: { id: string;