feat: add season/episode matching

This commit is contained in:
Viren070
2025-06-11 19:27:43 +01:00
parent 0b85ff35e7
commit 4cd6522417
7 changed files with 424 additions and 238 deletions
+9 -1
View File
@@ -331,7 +331,15 @@ export const UserDataSchema = z.object({
hideErrors: z.boolean().optional(),
hideErrorsForResources: z.array(ResourceSchema).optional(),
tmdbAccessToken: z.string().optional(),
strictTitleMatch: z
titleMatching: z
.object({
mode: z.enum(['exact', 'contains']).optional(),
enabled: z.boolean().optional(),
requestTypes: z.array(z.string()).optional(),
addons: z.array(z.string()).optional(),
})
.optional(),
seasonEpisodeMatching: z
.object({
enabled: z.boolean().optional(),
requestTypes: z.array(z.string()).optional(),
+96 -17
View File
@@ -1085,7 +1085,8 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
details: Record<string, number>;
}
const skipReasons: Record<string, SkipReason> = {
strictTitleMatching: { total: 0, details: {} },
titleMatching: { total: 0, details: {} },
seasonEpisodeMatching: { total: 0, details: {} },
excludedStreamType: { total: 0, details: {} },
requiredStreamType: { total: 0, details: {} },
excludedResolution: { total: 0, details: {} },
@@ -1117,7 +1118,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
const isRegexAllowed = FeatureControl.isRegexAllowed(this.userData);
let titles: string[] = [];
if (this.userData.strictTitleMatch && TYPES.includes(type as any)) {
if (this.userData.titleMatching && TYPES.includes(type as any)) {
try {
titles = await new TMDBMetadata().getTitles(id, type as any);
logger.info(`Found ${titles.length} titles for ${id}`, { titles });
@@ -1126,8 +1127,15 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
}
}
const normaliseTitle = (title: string) => {
return title
.replace(/[^\p{L}\p{N}+]/gu, '')
.replace(/\s+/g, '')
.toLowerCase();
};
const performTitleMatch = (stream: ParsedStream) => {
const titleMatchingOptions = this.userData.strictTitleMatch;
const titleMatchingOptions = this.userData.titleMatching;
if (!titleMatchingOptions || !titleMatchingOptions.enabled) {
return true;
}
@@ -1155,17 +1163,76 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
return true;
}
return titles.some(
(title) =>
title
.replace(/[^\p{L}\p{N}+]/gu, '')
.replace(/\s+/g, '')
.toLowerCase() ===
streamTitle
.replace(/[^\p{L}\p{N}+]/gu, '')
.replace(/\s+/g, '')
.toLowerCase()
);
if (titleMatchingOptions.mode === 'exact') {
// the stream title should be an exact match of a valid title
return titles.some(
(title) => normaliseTitle(title) === normaliseTitle(streamTitle)
);
} else {
// a valid title should be present somewhere in the stream title
const valid = titles.some((title) =>
normaliseTitle(streamTitle).includes(normaliseTitle(title))
);
return valid;
}
};
const performSeasonEpisodeMatch = (stream: ParsedStream) => {
const seasonEpisodeMatchingOptions = this.userData.seasonEpisodeMatching;
if (
!seasonEpisodeMatchingOptions ||
!seasonEpisodeMatchingOptions.enabled
) {
return true;
}
// parse the id to get the season and episode
const seasonEpisodeRegex = /:(\d+):(\d+)$/;
const match = id.match(seasonEpisodeRegex);
if (!match || !match[1] || !match[2]) {
// only if both season and episode are present, we can filter
return true;
}
const requestedSeason = parseInt(match[1]);
const requestedEpisode = parseInt(match[2]);
if (
seasonEpisodeMatchingOptions.requestTypes?.length &&
!seasonEpisodeMatchingOptions.requestTypes.includes(type)
) {
return true;
}
if (
seasonEpisodeMatchingOptions.addons?.length &&
!seasonEpisodeMatchingOptions.addons.includes(stream.addon.id!)
) {
return true;
}
// is requested season present
if (
(requestedSeason &&
stream.parsedFile?.season &&
stream.parsedFile.season !== requestedSeason) ||
(stream.parsedFile?.seasons &&
!stream.parsedFile.seasons.includes(requestedSeason))
) {
return false;
}
// is requested episode present
if (
requestedEpisode &&
stream.parsedFile?.episode &&
stream.parsedFile.episode !== requestedEpisode
) {
return false;
}
return true;
};
const excludedRegexPatterns =
@@ -1731,16 +1798,28 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
}
if (!performTitleMatch(stream)) {
skipReasons.strictTitleMatching.total++;
skipReasons.strictTitleMatching.details[
skipReasons.titleMatching.total++;
skipReasons.titleMatching.details[
stream.parsedFile?.title || 'Unknown'
] =
(skipReasons.strictTitleMatching.details[
(skipReasons.titleMatching.details[
stream.parsedFile?.title || 'Unknown'
] || 0) + 1;
return false;
}
if (!performSeasonEpisodeMatch(stream)) {
const detail =
stream.parsedFile?.title +
' ' +
(stream.parsedFile?.seasonEpisode?.join(' x ') || 'Unknown');
skipReasons.seasonEpisodeMatching.total++;
skipReasons.seasonEpisodeMatching.details[detail] =
(skipReasons.seasonEpisodeMatching.details[detail] || 0) + 1;
return false;
}
const useMinMax = (
minMax: [number, number] | undefined,
defaults: { min: number; max: number }
@@ -39,7 +39,10 @@ export function ConfigModal({
return;
}
setUserData(result.data.config);
setUserData((prev) => ({
...prev,
...result.data!.config, // we just checked that this is not null
}));
setUuid(uuid);
setPassword(password);
setEncryptedPassword(result.data.encryptedPassword);
+293 -193
View File
@@ -15,6 +15,7 @@ import {
FaRegTrashAlt,
FaFileExport,
FaFileImport,
FaEquals,
} from 'react-icons/fa';
import { FaTextSlash } from 'react-icons/fa6';
import {
@@ -267,9 +268,9 @@ function Content() {
<MdPerson className="text-lg mr-3" />
Seeders
</TabsTrigger>
<TabsTrigger value="strict-title-matching">
<FaTextSlash className="text-lg mr-3" />
Strict Title Matching
<TabsTrigger value="title-matching">
<FaEquals className="text-lg mr-3" />
Matching
</TabsTrigger>
<TabsTrigger value="keyword">
<FaTextSlash className="text-lg mr-3" />
@@ -523,28 +524,28 @@ function Content() {
excludedOptions={userData.excludedResolutions || []}
includedOptions={userData.includedResolutions || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredResolutions: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredResolutions: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedResolutions: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedResolutions: included,
});
}));
}}
options={RESOLUTIONS.map((resolution) => ({
name: resolution,
@@ -565,28 +566,28 @@ function Content() {
excludedOptions={userData.excludedQualities || []}
includedOptions={userData.includedQualities || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredQualities: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredQualities: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedQualities: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedQualities: included,
});
}));
}}
options={QUALITIES.map((quality) => ({
name: quality,
@@ -607,28 +608,28 @@ function Content() {
excludedOptions={userData.excludedEncodes || []}
includedOptions={userData.includedEncodes || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredEncodes: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredEncodes: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedEncodes: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedEncodes: included,
});
}));
}}
options={ENCODES.map((encode) => ({
name: encode,
@@ -649,28 +650,28 @@ function Content() {
excludedOptions={userData.excludedStreamTypes || []}
includedOptions={userData.includedStreamTypes || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredStreamTypes: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredStreamTypes: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedStreamTypes: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedStreamTypes: included,
});
}));
}}
options={STREAM_TYPES.map((streamType) => ({
name: streamType,
@@ -691,28 +692,28 @@ function Content() {
excludedOptions={userData.excludedVisualTags || []}
includedOptions={userData.includedVisualTags || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredVisualTags: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredVisualTags: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedVisualTags: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedVisualTags: included,
});
}));
}}
options={VISUAL_TAGS.map((visualTag) => ({
name: visualTag,
@@ -731,28 +732,28 @@ function Content() {
excludedOptions={userData.excludedAudioTags || []}
includedOptions={userData.includedAudioTags || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredAudioTags: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredAudioTags: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedAudioTags: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedAudioTags: included,
});
}));
}}
options={AUDIO_TAGS.map((audioTag) => ({
name: audioTag,
@@ -771,28 +772,28 @@ function Content() {
excludedOptions={userData.excludedAudioChannels || []}
includedOptions={userData.includedAudioChannels || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredAudioChannels: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredAudioChannels: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedAudioChannels: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedAudioChannels: included,
});
}));
}}
options={AUDIO_CHANNELS.map((audioChannel) => ({
name: audioChannel,
@@ -811,28 +812,28 @@ function Content() {
excludedOptions={userData.excludedLanguages || []}
includedOptions={userData.includedLanguages || []}
onPreferredChange={(preferred) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
preferredLanguages: preferred,
});
}));
}}
onRequiredChange={(required) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
requiredLanguages: required,
});
}));
}}
onExcludedChange={(excluded) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
excludedLanguages: excluded,
});
}));
}}
onIncludedChange={(included) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
includedLanguages: included,
});
}));
}}
options={LANGUAGES.map((language) => ({
name: language
@@ -952,104 +953,203 @@ function Content() {
</SettingsCard>
</PageWrapper>
</TabsContent>
<TabsContent value="strict-title-matching" className="space-y-4">
<TabsContent value="title-matching" className="space-y-4">
<PageWrapper>
<HeadingWithPageControls heading="Strict Title Matching" />
<SettingsCard
title="Strict Title Matching"
description="Any streams which don't specifically match the requested title will be filtered out. You can optionally choose to only apply it to specific request types and addons"
>
<Switch
label="Enabled"
side="right"
value={userData.strictTitleMatch?.enabled}
onValueChange={(value) => {
setUserData({
...userData,
strictTitleMatch: {
...userData.strictTitleMatch,
enabled: value,
},
});
}}
/>
<HeadingWithPageControls heading="Matching" />
<div className="space-y-4">
<SettingsCard
title="Title Matching"
description="Any streams which don't specifically match the requested title will be filtered out. You can optionally choose to only apply it to specific request types and addons"
>
<Switch
label="Enabled"
side="right"
value={userData.titleMatching?.enabled ?? false}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
titleMatching: {
...(prev.titleMatching || {}),
enabled: value,
},
}));
}}
/>
<TextInput
label="TMDB Access Token"
help={
<>
<p>
A TMDB access token is required to fetch titles from the
TMDB API. You can get it from your{' '}
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
className="text-[--brand] hover:underline"
rel="noopener noreferrer"
>
TMDB Account Settings
</a>
</p>
<p></p>
</>
}
required={!status?.settings.tmdbApiAvailable}
value={userData.tmdbAccessToken}
type="password"
placeholder="Enter your TMDB access token"
onValueChange={(value) => {
setUserData({
...userData,
tmdbAccessToken: value,
});
}}
/>
<Select
disabled={!userData.titleMatching?.enabled}
label="Matching Mode"
options={['exact', 'contains'].map((mode) => ({
label: mode,
value: mode,
}))}
defaultValue="exact"
value={userData.titleMatching?.mode}
help={
userData.titleMatching?.mode === 'contains'
? "Streams whose detected title doesn't contain the requested title will be excluded"
: "Streams whose detected title doesn't match the requested title exactly will be excluded"
}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
titleMatching: {
...prev.titleMatching,
mode: value as 'exact' | 'contains' | undefined,
},
}));
}}
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Combobox
label="Request Types"
emptyMessage="There aren't any request types to choose from..."
help="Request types that will use strict title matching. Leave blank to apply to all request types."
options={TYPES.map((type) => ({
label: type,
value: type,
text: type,
}))}
value={userData.strictTitleMatch?.requestTypes}
onValueChange={(value) => {
setUserData({
...userData,
strictTitleMatch: {
...userData.strictTitleMatch,
requestTypes: value,
},
});
}}
/>
<Combobox
label="Addons"
help="Addons that will use strict title matching. Leave blank to apply to all addons."
emptyMessage="You haven't installed any addons yet..."
options={userData.presets.map((preset) => ({
label: preset.options.name,
type: preset.options.name,
value: JSON.stringify(preset),
}))}
value={userData.strictTitleMatch?.addons || []}
onValueChange={(value) => {
setUserData({
...userData,
strictTitleMatch: {
...userData.strictTitleMatch,
addons: value,
},
});
}}
/>
<TextInput
label="TMDB Access Token"
help={
<>
<p>
A TMDB access token is required to fetch titles from
the TMDB API. You can get it from your{' '}
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
className="text-[--brand] hover:underline"
rel="noopener noreferrer"
>
TMDB Account Settings
</a>
</p>
<p></p>
</>
}
disabled={!userData.titleMatching?.enabled}
required={!status?.settings.tmdbApiAvailable}
value={userData.tmdbAccessToken}
type="password"
placeholder="Enter your TMDB access token"
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
tmdbAccessToken: value,
}));
}}
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Combobox
disabled={!userData.titleMatching?.enabled}
label="Request Types"
emptyMessage="There aren't any request types to choose from..."
help="Request types that will use strict title matching. Leave blank to apply to all request types."
options={TYPES.map((type) => ({
label: type,
value: type,
text: type,
}))}
value={userData.titleMatching?.requestTypes}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
titleMatching: {
...prev.titleMatching,
requestTypes: value,
},
}));
}}
/>
<Combobox
disabled={!userData.titleMatching?.enabled}
label="Addons"
help="Addons that will use strict title matching. Leave blank to apply to all addons."
emptyMessage="You haven't installed any addons yet..."
options={userData.presets.map((preset) => ({
label: preset.options.name,
type: preset.options.name,
value: JSON.stringify(preset),
}))}
value={userData.titleMatching?.addons || []}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
titleMatching: {
...prev.titleMatching,
addons: value,
},
}));
}}
/>
</div>
</div>
</div>
</SettingsCard>
</SettingsCard>
<SettingsCard
title="Season/Episode Matching"
description="Any streams which don't specifically match the requested season/episode will be filtered out. You can optionally choose to only apply it to specific request types and addons"
>
<Switch
label="Enabled"
side="right"
value={userData.seasonEpisodeMatching?.enabled ?? false}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
seasonEpisodeMatching: {
...(prev.seasonEpisodeMatching || {}),
enabled: value,
},
}));
}}
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Combobox
disabled={!userData.seasonEpisodeMatching?.enabled}
label="Request Types"
help="Request types that will use season/episode matching. Leave blank to apply to all request types."
emptyMessage="There aren't any request types to choose from..."
options={TYPES.map((type) => ({
label: type,
value: type,
text: type,
}))}
value={userData.seasonEpisodeMatching?.requestTypes}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
seasonEpisodeMatching: {
...prev.seasonEpisodeMatching,
requestTypes: value,
},
}));
}}
/>
<Combobox
disabled={!userData.seasonEpisodeMatching?.enabled}
label="Addons"
help="Addons that will use season/episode matching. Leave blank to apply to all addons."
emptyMessage="You haven't installed any addons yet..."
options={userData.presets.map((preset) => ({
label: preset.options.name,
type: preset.options.name,
value: JSON.stringify(preset),
}))}
value={userData.seasonEpisodeMatching?.addons || []}
onValueChange={(value) => {
setUserData((prev) => {
return {
...prev,
seasonEpisodeMatching: {
...prev.seasonEpisodeMatching,
addons: value,
},
};
});
}}
/>
</div>
</div>
</SettingsCard>
</div>
</PageWrapper>
</TabsContent>
<TabsContent value="keyword" className="space-y-4">
@@ -148,13 +148,13 @@ function Content() {
// Keep userData in sync with custom formatter fields
useEffect(() => {
if (selectedFormatter === constants.CUSTOM_FORMATTER) {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
formatter: {
id: constants.CUSTOM_FORMATTER,
definition: { name: customName, description: customDescription },
},
});
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customName, customDescription, selectedFormatter]);
@@ -165,8 +165,8 @@ function Content() {
setCustomName(userData.formatter?.definition?.name || '');
setCustomDescription(userData.formatter?.definition?.description || '');
}
setUserData({
...userData,
setUserData((prev) => ({
...prev,
formatter: {
id: value as constants.FormatterType,
definition:
@@ -177,7 +177,7 @@ function Content() {
}
: undefined,
},
});
}));
};
const formatStream = useCallback(async () => {
@@ -40,10 +40,10 @@ function Content() {
side="right"
value={userData.hideErrors}
onValueChange={(value) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
hideErrors: value,
});
}));
}}
/>
<Combobox
@@ -57,10 +57,10 @@ function Content() {
emptyMessage="No resources found"
value={userData.hideErrorsForResources}
onValueChange={(value) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
hideErrorsForResources: value as (typeof RESOURCES)[number][],
});
}));
}}
/>
</SettingsCard>
@@ -73,10 +73,10 @@ function Content() {
side="right"
value={userData.precacheNextEpisode}
onValueChange={(value) => {
setUserData({
...userData,
setUserData((prev) => ({
...prev,
precacheNextEpisode: value,
});
}));
}}
/>
</SettingsCard>
+7 -11
View File
@@ -42,9 +42,7 @@ const DefaultUserData: UserData = {
interface UserDataContextType {
userData: UserData;
setUserData: (
data: UserData | null | ((prev: UserData) => UserData | null)
) => void;
setUserData: (data: ((prev: UserData) => UserData | null) | null) => void;
uuid: string | null;
setUuid: (uuid: string | null) => void;
password: string | null;
@@ -66,17 +64,15 @@ export function UserDataProvider({ children }: { children: React.ReactNode }) {
>(null);
const safeSetUserData = (
data: UserData | null | ((prev: UserData) => UserData | null)
data: ((prev: UserData) => UserData | null) | null
) => {
if (typeof data === 'function') {
setUserData((prev) => {
const result = (data as (prev: UserData) => UserData | null)(prev);
return result === null ? DefaultUserData : result;
});
} else if (data === null) {
if (data === null) {
setUserData(DefaultUserData);
} else {
setUserData(data);
setUserData((prev) => {
const result = data(prev);
return result === null ? DefaultUserData : result;
});
}
};