diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 6787954d..e62c3ad3 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -356,6 +356,7 @@ export const UserDataSchema = z.object({ .tuple([z.number().min(0), z.number().min(0)]) .optional(), seederRangeTypes: z.array(z.enum(['p2p', 'cached', 'uncached'])).optional(), + digitalReleaseFilter: z.boolean().optional(), excludeSeasonPacks: z.boolean().optional(), excludeCached: z.boolean().optional(), excludeCachedFromAddons: z.array(z.string().min(1)).optional(), diff --git a/packages/core/src/metadata/service.ts b/packages/core/src/metadata/service.ts index d25e5df1..4c4e4876 100644 --- a/packages/core/src/metadata/service.ts +++ b/packages/core/src/metadata/service.ts @@ -38,6 +38,7 @@ export class MetadataService { async () => { const start = Date.now(); const titles: string[] = []; + let releaseDate: string | undefined; let year: number | undefined; let yearEnd: number | undefined; let seasons: @@ -155,6 +156,8 @@ export class MetadataService { if (tmdbMetadata.titles) titles.push(...tmdbMetadata.titles); if (tmdbMetadata.year) year = tmdbMetadata.year; if (tmdbMetadata.yearEnd) yearEnd = tmdbMetadata.yearEnd; + if (tmdbMetadata.releaseDate) + releaseDate = tmdbMetadata.releaseDate; if (tmdbMetadata.seasons) seasons = tmdbMetadata.seasons.sort( (a, b) => a.season_number - b.season_number @@ -275,6 +278,7 @@ export class MetadataService { year, yearEnd, seasons, + releaseDate, tmdbId, tvdbId, }; diff --git a/packages/core/src/metadata/tmdb.ts b/packages/core/src/metadata/tmdb.ts index b3c4474d..bbbdac6a 100644 --- a/packages/core/src/metadata/tmdb.ts +++ b/packages/core/src/metadata/tmdb.ts @@ -103,6 +103,23 @@ const FindResultsSchema = z.object({ ), }); +const ReleaseDateSchema = z.object({ + release_date: z.string(), + type: z.number().min(0).max(6), +}); + +export type ReleaseDate = z.infer; + +const ReleaseDatesResponseSchema = z.object({ + id: z.number(), + results: z.array( + z.object({ + iso_3166_1: z.string(), + release_dates: z.array(ReleaseDateSchema), + }) + ), +}); + const IdTypeMap: Partial> = { imdbId: 'imdb_id', thetvdbId: 'tvdb_id', @@ -240,11 +257,11 @@ export class TMDBMetadata { parsedId.mediaType === 'movie' ? (detailsData as z.infer).title : (detailsData as z.infer).name; - const year = this.parseReleaseDate( + const releaseDate = parsedId.mediaType === 'movie' ? (detailsData as z.infer).release_date - : (detailsData as z.infer).first_air_date - ); + : (detailsData as z.infer).first_air_date; + const year = this.parseReleaseDate(releaseDate); const yearEnd = parsedId.mediaType !== 'movie' ? (detailsData as z.infer).last_air_date @@ -350,6 +367,7 @@ export class TMDBMetadata { const metadata: Metadata = { title: primaryTitle, titles: uniqueTitles, + releaseDate: releaseDate, year: Number(year), yearEnd: yearEnd ? Number(yearEnd) : undefined, seasons, @@ -367,6 +385,21 @@ export class TMDBMetadata { } } + public async getReleaseDates(tmdbId: number): Promise { + const url = new URL(API_BASE_URL + `/movie/${tmdbId}/release_dates`); + this.addSearchParams(url); + const response = await makeRequest(url.toString(), { + timeout: 5000, + headers: this.getHeaders(), + }); + if (!response.ok) { + throw new Error(`Failed to fetch release dates: ${response.statusText}`); + } + const json = await response.json(); + const data = ReleaseDatesResponseSchema.parse(json); + return data.results.flatMap((result) => result.release_dates); + } + public async validateAuthorisation() { const cacheKey = this.accessToken || this.apiKey; if (!cacheKey) { diff --git a/packages/core/src/metadata/utils.ts b/packages/core/src/metadata/utils.ts index f59b8a1a..1b9125c4 100644 --- a/packages/core/src/metadata/utils.ts +++ b/packages/core/src/metadata/utils.ts @@ -3,6 +3,7 @@ export interface Metadata { titles?: string[]; year?: number; yearEnd?: number; + releaseDate?: string; seasons?: { season_number: number; episode_count: number; diff --git a/packages/core/src/streams/filterer.ts b/packages/core/src/streams/filterer.ts index 38135a97..dec2b02e 100644 --- a/packages/core/src/streams/filterer.ts +++ b/packages/core/src/streams/filterer.ts @@ -19,6 +19,7 @@ import { titleMatch } from '../parser/utils.js'; import { partial_ratio } from 'fuzzball'; import { calculateAbsoluteEpisode } from '../builtins/utils/general.js'; import { formatBytes } from '../formatters/utils.js'; +import { ReleaseDate, TMDBMetadata } from '../metadata/tmdb.js'; const logger = createLogger('filterer'); @@ -33,6 +34,7 @@ export interface FilterStatistics { yearMatching: Reason; seasonEpisodeMatching: Reason; excludeSeasonPacks: Reason; + noDigitalRelease: Reason; excludedStreamType: Reason; requiredStreamType: Reason; excludedResolution: Reason; @@ -91,6 +93,7 @@ class StreamFilterer { yearMatching: { total: 0, details: {} }, seasonEpisodeMatching: { total: 0, details: {} }, excludeSeasonPacks: { total: 0, details: {} }, + noDigitalRelease: { total: 0, details: {} }, excludedStreamType: { total: 0, details: {} }, requiredStreamType: { total: 0, details: {} }, excludedResolution: { total: 0, details: {} }, @@ -164,6 +167,38 @@ class StreamFilterer { return this.filterStatistics; } + private generateFilterSummary( + streams: ParsedStream[], + finalStreams: ParsedStream[], + start: number + ): void { + const totalFiltered = streams.length - finalStreams.length; + const summary = [ + '\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ` 🔍 Filter Summary`, + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ` 📊 Total Streams : ${streams.length}`, + ` ✔️ Kept : ${finalStreams.length}`, + ` ❌ Filtered : ${totalFiltered}`, + ]; + + // Add filter details if any streams were filtered + const { filterDetails, includedDetails } = this.getFormattedFilterDetails(); + + if (filterDetails.length > 0) { + summary.push('\n 🔎 Filter Details:'); + summary.push(...filterDetails); + } + if (includedDetails.length > 0) { + summary.push('\n 🔎 Included Details:'); + summary.push(...includedDetails); + } + summary.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + logger.info(summary.join('\n')); + + logger.info(`Applied filters in ${getTimeTakenSincePoint(start)}`); + } + public getFormattedFilterDetails(): { filterDetails: string[]; includedDetails: string[]; @@ -227,8 +262,10 @@ class StreamFilterer { | undefined; let yearWithinTitle: string | undefined; let yearWithinTitleRegex: RegExp | undefined; + let releaseDates: ReleaseDate[] | undefined; if ( (this.userData.titleMatching?.enabled || + (this.userData.digitalReleaseFilter && type === 'movie') || this.userData.yearMatching?.enabled || this.userData.seasonEpisodeMatching?.enabled) && constants.TYPES.includes(type as any) @@ -293,6 +330,23 @@ class StreamFilterer { requestedMetadata.absoluteEpisode = absoluteEpisode; } + if ( + this.userData.digitalReleaseFilter && + requestedMetadata.tmdbId && + type === 'movie' + ) { + try { + releaseDates = await new TMDBMetadata({ + accessToken: this.userData.tmdbAccessToken, + apiKey: this.userData.tmdbApiKey, + }).getReleaseDates(requestedMetadata.tmdbId); + } catch (error) { + logger.warn( + `Error fetching release dates for ${id} (tmdb: ${requestedMetadata.tmdbId}): ${error}` + ); + } + } + yearWithinTitle = requestedMetadata.title.match( /\b(19\d{2}|20[012]\d{1})\b/ )?.[0]; @@ -315,6 +369,83 @@ class StreamFilterer { .toLowerCase(); }; + const applyDigitalReleaseFilter = () => { + logger.debug(`Applying digital release filter for ${id}`, { + releaseDate: requestedMetadata?.releaseDate, + type, + tmdbId: requestedMetadata?.tmdbId, + digitalReleaseFilter: this.userData.digitalReleaseFilter, + releaseDates: releaseDates?.length, + }); + if (!this.userData.digitalReleaseFilter) { + return true; + } + + if (type !== 'movie') { + // only appply digital release filter to movies + return true; + } + + if (!requestedMetadata?.releaseDate) { + // if no release date is present, keep due to lack of data + return true; + } + + const releaseDate = new Date(requestedMetadata.releaseDate); + if (isNaN(releaseDate.getTime())) { + // if the release date is invalid, keep due to lack of data + return true; + } + const today = new Date(); + + const daysSinceRelease = Math.floor( + (today.getTime() - releaseDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysSinceRelease < 0) { + // a movie released in the future is not digitaly released yet. + logger.debug( + `Filtering out all results as movie is not digitally released yet`, + { + title: requestedMetadata?.title, + daysSinceRelease, + } + ); + return false; + } + + if (daysSinceRelease > 365) { + // a movie released more than a year ago is likely to have a digital release. + return true; + } + + if (!releaseDates || releaseDates.length === 0) { + // if release dates couldn't be fetched for a recent movie, keep due to lack of data + return true; + } + + // check if at least one of the release dates is in the past and return true if so + const hasDigitalRelease = releaseDates.some( + (releaseDate) => + releaseDate.type >= 4 && + releaseDate.type <= 6 && + new Date(releaseDate.release_date) <= today + ); + + if (hasDigitalRelease) { + return true; + } + + logger.debug( + `Filtering out all results as no digital release was found`, + { + title: requestedMetadata?.title, + daysSinceRelease, + } + ); + return false; + }; + const performTitleMatch = (stream: ParsedStream) => { const titleMatchingOptions = { mode: 'exact', @@ -564,6 +695,19 @@ class StreamFilterer { return true; }; + // Early digital release filter check - if it returns false, filter out all streams + if (!applyDigitalReleaseFilter()) { + // Set the count to total streams since ALL streams are filtered out + this.filterStatistics.removed.noDigitalRelease.total = streams.length; + this.filterStatistics.removed.noDigitalRelease.details[ + 'No digital release available' + ] = streams.length; + + const finalStreams: ParsedStream[] = []; + this.generateFilterSummary(streams, finalStreams, start); + return finalStreams; + } + const excludedRegexPatterns = isRegexAllowed && this.userData.excludedRegexPatterns && @@ -1367,33 +1511,8 @@ class StreamFilterer { ...filteredStreams, ]); - // L// filter summary - const totalFiltered = streams.length - finalStreams.length; - - const summary = [ - '\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ` 🔍 Filter Summary`, - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ` 📊 Total Streams : ${streams.length}`, - ` ✔️ Kept : ${finalStreams.length}`, - ` ❌ Filtered : ${totalFiltered}`, - ]; - - // Add filter details if any streams were filtered - const { filterDetails, includedDetails } = this.getFormattedFilterDetails(); - - if (filterDetails.length > 0) { - summary.push('\n 🔎 Filter Details:'); - summary.push(...filterDetails); - } - if (includedDetails.length > 0) { - summary.push('\n 🔎 Included Details:'); - summary.push(...includedDetails); - } - summary.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - logger.info(summary.join('\n')); - - logger.info(`Applied filters in ${getTimeTakenSincePoint(start)}`); + // Generate filter summary + this.generateFilterSummary(streams, finalStreams, start); return finalStreams; } diff --git a/packages/frontend/src/components/menu/filters.tsx b/packages/frontend/src/components/menu/filters.tsx index 5c066d2c..dbacab0d 100644 --- a/packages/frontend/src/components/menu/filters.tsx +++ b/packages/frontend/src/components/menu/filters.tsx @@ -28,6 +28,7 @@ import { MdSurroundSound, MdTextFields, MdVideoLibrary, + MdMiscellaneousServices, } from 'react-icons/md'; import { BiSolidCameraMovie } from 'react-icons/bi'; import { SiDolby } from 'react-icons/si'; @@ -341,6 +342,10 @@ function Content() { Deduplicator + + + Miscellaneous + @@ -2444,6 +2449,59 @@ function Content() { + + <> + +
+

+ Additional miscellaneous filters. +

+
+
+ {/* + + */} + + { + setUserData((prev) => ({ + ...prev, + digitalReleaseFilter: value, + })); + }} + /> + + {mode === 'pro' && ( + + { + setUserData((prev) => ({ + ...prev, + excludeSeasonPacks: value, + })); + }} + /> + + )} +
+ +
diff --git a/packages/frontend/src/components/menu/miscellaneous.tsx b/packages/frontend/src/components/menu/miscellaneous.tsx index 3c36ef1a..34e001cf 100644 --- a/packages/frontend/src/components/menu/miscellaneous.tsx +++ b/packages/frontend/src/components/menu/miscellaneous.tsx @@ -362,24 +362,6 @@ function Content() { /> )} - {mode === 'pro' && ( - - { - setUserData((prev) => ({ - ...prev, - excludeSeasonPacks: value, - })); - }} - /> - - )} {mode === 'pro' && (