feat: add digital release filter to miscellaneous filter tab

closes #280
This commit is contained in:
Viren070
2025-10-19 15:52:55 +01:00
parent 21458ca447
commit 2bd98ea6f9
7 changed files with 246 additions and 48 deletions
+1
View File
@@ -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(),
+4
View File
@@ -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,
};
+36 -3
View File
@@ -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<typeof ReleaseDateSchema>;
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<Record<IdType, TMDBIdType>> = {
imdbId: 'imdb_id',
thetvdbId: 'tvdb_id',
@@ -240,11 +257,11 @@ export class TMDBMetadata {
parsedId.mediaType === 'movie'
? (detailsData as z.infer<typeof MovieDetailsSchema>).title
: (detailsData as z.infer<typeof TVDetailsSchema>).name;
const year = this.parseReleaseDate(
const releaseDate =
parsedId.mediaType === 'movie'
? (detailsData as z.infer<typeof MovieDetailsSchema>).release_date
: (detailsData as z.infer<typeof TVDetailsSchema>).first_air_date
);
: (detailsData as z.infer<typeof TVDetailsSchema>).first_air_date;
const year = this.parseReleaseDate(releaseDate);
const yearEnd =
parsedId.mediaType !== 'movie'
? (detailsData as z.infer<typeof TVDetailsSchema>).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<ReleaseDate[]> {
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) {
+1
View File
@@ -3,6 +3,7 @@ export interface Metadata {
titles?: string[];
year?: number;
yearEnd?: number;
releaseDate?: string;
seasons?: {
season_number: number;
episode_count: number;
+146 -27
View File
@@ -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;
}
@@ -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() {
<MdCleaningServices className="text-lg mr-3" />
Deduplicator
</TabsTrigger>
<TabsTrigger value="miscellaneous">
<MdMiscellaneousServices className="text-lg mr-3" />
Miscellaneous
</TabsTrigger>
</div>
</SettingsNavCard>
</TabsList>
@@ -2444,6 +2449,59 @@ function Content() {
</div>
</>
</TabsContent>
<TabsContent value="miscellaneous" className="space-y-4">
<>
<HeadingWithPageControls heading="Miscellaneous" />
<div className="mb-4">
<p className="text-sm text-[--muted]">
Additional miscellaneous filters.
</p>
</div>
<div className="space-y-4">
{/* <SettingsCard>
<Switch
label="Enable"
side="right"
value={userData.miscellaneous?.enabled ?? false}
/>
</SettingsCard> */}
<SettingsCard
title="Digital Release Filter"
description="This will filter out all results for movies that are determined to not have a digital release."
>
<Switch
label="Enable"
side="right"
value={userData.digitalReleaseFilter}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
digitalReleaseFilter: value,
}));
}}
/>
</SettingsCard>
{mode === 'pro' && (
<SettingsCard
title="Exclude Season Packs"
description="Whether to filter out results that contain entire seasons."
>
<Switch
label="Enable"
side="right"
value={userData.excludeSeasonPacks}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeSeasonPacks: value,
}));
}}
/>
</SettingsCard>
)}
</div>
</>
</TabsContent>
</div>
</Tabs>
@@ -362,24 +362,6 @@ function Content() {
/>
</SettingsCard>
)}
{mode === 'pro' && (
<SettingsCard
title="Exclude Season Packs"
description="Whether to filter out results that contain entire seasons."
>
<Switch
label="Enable"
side="right"
value={userData.excludeSeasonPacks}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeSeasonPacks: value,
}));
}}
/>
</SettingsCard>
)}
{mode === 'pro' && (
<SettingsCard title="Hide Errors">
<Switch